Add time machine plugin: record state history and travel back/forward in time#519
Add time machine plugin: record state history and travel back/forward in time#519aidenybai wants to merge 6 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
commit: |
There was a problem hiding this comment.
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
| shortcut: "H", | ||
| shortcutModifier: false, | ||
| showInToolbarMenu: true, | ||
| enabled: () => hasTimeMachineHistory(), |
There was a problem hiding this comment.
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>
| *.tsbuildinfo | ||
| next-env.d.ts | ||
| .env*.local | ||
| public/install-hook.js |
There was a problem hiding this comment.
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>
|
Deployment failed with the following error: Learn More: https://vercel.com/docs/concepts/projects/project-configuration |
There was a problem hiding this comment.
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
| const changes = collectHookChanges(fiber); | ||
| if (!changes) return; | ||
| if (commitChanges) { | ||
| commitChanges.push(...changes); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>
75b2494 to
a96f843
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
There was a problem hiding this comment.
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
… 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>
bd0971f to
4716cb5
Compare
…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>
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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>

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/useReducerchange, 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 framebadge, 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;traverseRenderedFibersvisits only fibers that actually rendered.alternatein lockstep, so no baseline bookkeeping is needed.useStateanduseReducerhooks are recorded (identified byqueue.lastRenderedReducer, with a behavioral probe classifying which of the two since prod minifiesbasicStateReducer's name).useSyncExternalStoreis deliberately excluded: its state lives in an external store that has no generic setter, and React's consistency check re-readsgetSnapshot()after every render, immediately reverting any forced hook value — so its changes are neither recorded nor restored.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 viaWeakRef. The recorder is dead-code-eliminated from the display-only demo build (IS_DEMO).Performance attribution
getTimings(fiber).selfTimeper 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.long-animation-framePerformanceObserver(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 theDate.now()/performance.timeOrigindrift) is charged itsdurationandblockingDuration. Coalesced bursts accumulate both metrics.hasPerfIssue) when it sat inside a LoAF or its render cost exceeded one 60fps frame budget (16ms).Travelling
useStatehooks replay through their ownqueue.dispatch(() => value)— exact becausebasicStateReducerapplies function actions to current state. React DevTools'overrideHookStatebridge was not viable: under React Refresh (Vite/Next dev) the injected renderer is never retained anywhere reachable.useReducerhooks can't be forced through dispatch (actions feed the app's reducer), so travel writesmemoizedState/baseStatedirectly on both the fiber and its alternate (making the forced commit diff-invisible to the recorder) plusqueue.lastRenderedState(keeping eager-dispatch bailouts truthful), then forces a re-render: preferably by "wiggling" a siblinguseStatequeue (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'smemoizedPropsidentities are invalidated (the same trick DevTools uses) so React's render-phase bailout can't discard the forced render.Time freeze while rewound
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 globalanimation-play-state: paused+transition: nonestylesheet makes scrub steps snap instead of tween. Returning to now (or closing) restores each animation to its captured time and resumes it seamlessly.core/time-machine-page-clock.ts): apps also animate throughrequestAnimationFrameloops,setTimeoutchains, andsetIntervaltickers 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.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!importantinline styles. Purely visual; focus never moves. Pins swap as you scrub and release at now/close/dispose.UI (
components/time-machine-panel/)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).182ms frame/23ms render) when the current entry is flagged.H) opens element-scoped with the page frozen; disabled until any change is recorded.ValueStepper+StepArrowcontrols: ←/→ (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.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 withdangerouslyRunInProduction+ the behavioral reducer probe), and productionreact-domonly talks to the DevTools hook if it exists when react-dom evaluates — integrations whose script loads late need bippy'sinstall-hook-onlyshim 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 functionon load. This was a pre-existing storybook build issue (reproduces identically onmain), surfaced while validating this branch's preview: the openstory CLI merges an inline, optionlessvite-plugin-solidinstance with the config file's plugins, so theexclude: [/\.react\.tsx$/]on the configuredsolid()never stopped the duplicate from compiling the*.react.tsxdemo components as Solid JSX — which React then rendered and crashed on.apps/openstory/vite.config.tsnow pre-compiles those files' JSX with the React automatic runtime in anenforce: "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/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.currentTimeruns backward while scrubbing back.pnpm lint,pnpm typecheck,pnpm formatclean.To show artifacts inline, enable in settings.
Summary by cubic
Adds a built-in Time Machine to
react-grabthat records everyuseState/useReducerchange viabippyand 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
useState/useReducerwith coalesced animation bursts, cap 200, prev/next values, travel commits ignored, redo tail truncated; production-safe via reducer probe;useSyncExternalStoreexcluded; demo build omits recorder.useStateviaqueue.dispatch(() => value);useReducerby writing hook state on both fiber buffers and forcing a bailout-proof re-render.timeMachinePluginwithTimeMachinePanelStateandTimeMachineTimelineEntrytypes.Bug Fixes
thisbinding and replayed callbacks re‑park through wrappers.*.react.tsxwith the React runtime to avoidvite-plugin-solidtransforming them as Solid JSX, preventing runtime crashes.Written for commit 1f38fe9. Summary will update on new commits.