diff --git a/.changeset/time-machine-plugin.md b/.changeset/time-machine-plugin.md
new file mode 100644
index 000000000..be56ae646
--- /dev/null
+++ b/.changeset/time-machine-plugin.md
@@ -0,0 +1,5 @@
+---
+"react-grab": patch
+---
+
+Add a time machine plugin that records every React state change (via bippy commit instrumentation) and lets you scrub backward and forward through that history. A new toolbar button (clock icon) opens a mini scrubber panel directly — no element selection required, and the app stays live so new changes keep appending to the timeline — while a built-in "Time Machine" context-menu action (shortcut H) opens it scoped to a selected element with the page frozen. The panel reuses the style panel's slider-with-step-arrows control: ←/→ (or dragging the track) travels the app one recorded change at a time. Travel restores exact hook values — useState through each hook's own dispatcher, useReducer by writing the hook state directly on both fiber buffers and forcing a bailout-defeating re-render (useSyncExternalStore is deliberately excluded: external stores cannot be written back). Making a new change while rewound forks the timeline like undo/redo. Rewinding also stops the page's clock entirely: CSS/WAAPI animations pause (loops seek to where they were at the rewound moment; finite transitions hold their settled pose instead of replaying), and the page's own scheduling — requestAnimationFrame loops, setTimeout chains, setInterval tickers — is suspended so JS-driven animation engines hold still too, with parked callbacks replayed when time resumes. Animation-tick state bursts (text scrambles, count-ups) coalesce into single timeline entries so scrubbing lands only on settled moments, never garbled mid-animation frames, while commits near real pointer/keyboard input always stay distinct steps. Interaction visuals travel too — each entry records the hover/focus/active styling active at that moment, and rewinding pins it back on (purely visually; focus never actually moves). Works against production React builds too (useState hooks are detected by probing the reducer's behavior since prod minifies `basicStateReducer`'s name). The plugin is exported as `timeMachinePlugin` for standalone use.
diff --git a/apps/e2e-app-vite/src/App.tsx b/apps/e2e-app-vite/src/App.tsx
index 17777e504..749ebd80a 100644
--- a/apps/e2e-app-vite/src/App.tsx
+++ b/apps/e2e-app-vite/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useReducer, useSyncExternalStore } from "react";
import { PerfGrid } from "./perf-grid";
interface Todo {
@@ -614,6 +614,157 @@ const PointerUpModalSection = () => {
);
};
+interface CounterState {
+ count: number;
+}
+
+type CounterAction = { type: "increment" } | { type: "decrement" };
+
+const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
+ switch (action.type) {
+ case "increment":
+ return { count: state.count + 1 };
+ case "decrement":
+ return { count: state.count - 1 };
+ default:
+ return state;
+ }
+};
+
+// Reducer-only component (no useState of its own): time-machine travel must
+// force its re-render through a stateful ancestor.
+const ReducerOnlyCounter = () => {
+ const [state, dispatch] = useReducer(counterReducer, { count: 0 });
+
+ return (
+
+ {state.count}
+ dispatch({ type: "increment" })}
+ className="border px-2 py-1 rounded"
+ data-testid="reducer-only-increment"
+ >
+ Increment
+
+
+ );
+};
+
+// useReducer alongside useState on the same fiber: time-machine travel can
+// force its re-render through the sibling useState queue.
+const MixedHooksCounter = () => {
+ const [step, setStep] = useState(1);
+ const [state, dispatch] = useReducer(counterReducer, { count: 0 });
+
+ return (
+
+ {state.count}
+ dispatch({ type: "increment" })}
+ className="border px-2 py-1 rounded"
+ data-testid="mixed-hooks-increment"
+ >
+ Increment
+
+ setStep((previous) => previous + 1)}
+ className="border px-2 py-1 rounded"
+ data-testid="mixed-hooks-step"
+ >
+ Step {step}
+
+
+ );
+};
+
+const externalStore = {
+ value: 0,
+ listeners: new Set<() => void>(),
+ increment() {
+ this.value += 1;
+ this.listeners.forEach((listener) => listener());
+ },
+ subscribe(listener: () => void) {
+ externalStore.listeners.add(listener);
+ return () => externalStore.listeners.delete(listener);
+ },
+ getSnapshot() {
+ return externalStore.value;
+ },
+};
+
+// useSyncExternalStore state lives outside React and cannot be restored by
+// the time machine — it must be neither recorded nor travelled.
+const ExternalStoreCounter = () => {
+ const value = useSyncExternalStore(externalStore.subscribe, externalStore.getSnapshot);
+
+ return (
+
+ {value}
+ externalStore.increment()}
+ className="border px-2 py-1 rounded"
+ data-testid="external-store-increment"
+ >
+ Increment
+
+
+ );
+};
+
+const blockMainThread = (durationMs: number) => {
+ const blockStart = performance.now();
+ while (performance.now() - blockStart < durationMs) {
+ // Busy-wait: fixture for slow-render / long-animation-frame detection.
+ }
+};
+
+// Every increment blocks the main thread during render, producing both a
+// slow commit (profiling actualDuration) and a long-animation-frame entry
+// for the time machine's perf attribution to flag.
+const JankyCounter = () => {
+ const [count, setCount] = useState(0);
+ if (count > 0) blockMainThread(80);
+
+ return (
+
+ {count}
+ setCount((previous) => previous + 1)}
+ className="border px-2 py-1 rounded"
+ data-testid="janky-increment"
+ >
+ Janky Increment
+
+
+ );
+};
+
+const ReducerSection = () => {
+ const [isExpanded, setIsExpanded] = useState(true);
+
+ return (
+
+ Reducer Counters
+ setIsExpanded((previous) => !previous)}
+ className="border px-2 py-1 rounded mb-2"
+ data-testid="reducer-section-toggle"
+ >
+ {isExpanded ? "Collapse" : "Expand"}
+
+ {isExpanded && (
+
+
+
+
+
+
+ )}
+
+ );
+};
+
const HiddenToggleSection = () => {
const [isVisible, setIsVisible] = useState(true);
const elementRef = useRef(null);
@@ -754,6 +905,8 @@ export default function App() {
+
+
({
+ name: "react-grab:react-file-jsx",
+ enforce: "pre",
+ transform(code, id) {
+ const filename = id.replace(/\?.*$/, "");
+ if (!REACT_FILE_PATTERN.test(filename)) return null;
+ return transformWithOxc(code, filename, {
+ jsx: { runtime: "automatic", importSource: "react" },
+ });
+ },
+});
+
export default defineConfig({
plugins: [
+ reactFileJsxPlugin(),
solid({
exclude: [REACT_FILE_PATTERN],
}),
diff --git a/packages/react-grab/e2e/time-machine.spec.ts b/packages/react-grab/e2e/time-machine.spec.ts
new file mode 100644
index 000000000..a2b078737
--- /dev/null
+++ b/packages/react-grab/e2e/time-machine.spec.ts
@@ -0,0 +1,488 @@
+import type { Page } from "@playwright/test";
+import { expect, test } from "./fixtures.js";
+import type { ReactGrabPageObject } from "./fixtures.js";
+import { ATTRIBUTE_NAME } from "./constants.js";
+
+const TIME_MACHINE_PANEL_ATTR = "data-react-grab-time-machine-panel";
+const TIME_MACHINE_CLOCK_ATTR = "data-react-grab-time-machine-clock";
+const TOGGLE_BUTTON_SELECTOR = "[data-testid='toggle-visibility-button']";
+const TOGGLEABLE_ELEMENT_SELECTOR = "[data-testid='toggleable-element']";
+const SPINNER_SELECTOR = "[data-testid='animated-spin']";
+
+const getSpinnerAnimationPlayState = async (page: Page): Promise
=>
+ page.evaluate((spinnerSelector) => {
+ const spinnerElement = document.querySelector(spinnerSelector);
+ if (!spinnerElement) return null;
+ const spinnerAnimation = spinnerElement.getAnimations()[0];
+ return spinnerAnimation?.playState ?? null;
+ }, SPINNER_SELECTOR);
+
+const isTimeMachinePanelVisible = async (page: Page): Promise =>
+ page.evaluate(
+ ({ attrName, panelAttr }) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ if (!shadowRoot) return false;
+ return shadowRoot.querySelector(`[${panelAttr}]`) !== null;
+ },
+ { attrName: ATTRIBUTE_NAME, panelAttr: TIME_MACHINE_PANEL_ATTR },
+ );
+
+const getTimeMachineValueText = async (page: Page): Promise =>
+ page.evaluate(
+ ({ attrName, panelAttr }) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ if (!shadowRoot) return null;
+ const panel = shadowRoot.querySelector(`[${panelAttr}]`);
+ const valueElement = panel?.querySelector("[data-react-grab-value]");
+ return valueElement?.getAttribute("data-react-grab-value") ?? null;
+ },
+ { attrName: ATTRIBUTE_NAME, panelAttr: TIME_MACHINE_PANEL_ATTR },
+ );
+
+const getTimeMachineClockText = async (page: Page): Promise =>
+ page.evaluate(
+ ({ attrName, clockAttr }) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ if (!shadowRoot) return null;
+ const clockElement = shadowRoot.querySelector(`[${clockAttr}]`);
+ return clockElement?.textContent?.trim() ?? null;
+ },
+ { attrName: ATTRIBUTE_NAME, clockAttr: TIME_MACHINE_CLOCK_ATTR },
+ );
+
+const LIVE_PILL_ATTR = "data-react-grab-time-machine-live";
+
+const getLivePillState = async (page: Page): Promise =>
+ page.evaluate(
+ ({ attrName, livePillAttr }) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ if (!shadowRoot) return null;
+ const livePill = shadowRoot.querySelector(`[${livePillAttr}]`);
+ return livePill?.getAttribute(livePillAttr) ?? null;
+ },
+ { attrName: ATTRIBUTE_NAME, livePillAttr: LIVE_PILL_ATTR },
+ );
+
+const clickLivePill = async (page: Page): Promise =>
+ page.evaluate(
+ ({ attrName, livePillAttr }) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ const livePill = shadowRoot?.querySelector(`[${livePillAttr}]`);
+ livePill?.click();
+ },
+ { attrName: ATTRIBUTE_NAME, livePillAttr: LIVE_PILL_ATTR },
+ );
+
+const hasPerfFlaggedTimelineDot = async (page: Page): Promise =>
+ page.evaluate((attrName) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ if (!shadowRoot) return false;
+ return shadowRoot.querySelector("[data-react-grab-timeline-dot-perf]") !== null;
+ }, ATTRIBUTE_NAME);
+
+const openTimeMachinePanel = async (
+ reactGrab: ReactGrabPageObject,
+ selector: string,
+): Promise => {
+ await reactGrab.activate();
+ await reactGrab.hoverUntilSelected(selector);
+ await reactGrab.rightClickElement(selector);
+ await reactGrab.clickContextMenuItem("Time Machine");
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+};
+
+const clickToolbarTimeMachineButton = async (reactGrab: ReactGrabPageObject): Promise => {
+ await reactGrab.page.evaluate((attrName) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const shadowRoot = host?.shadowRoot;
+ const root = shadowRoot?.querySelector(`[${attrName}]`);
+ const timeMachineButton = root?.querySelector(
+ '[data-react-grab-toolbar-action="time-machine"]',
+ );
+ timeMachineButton?.click();
+ }, ATTRIBUTE_NAME);
+};
+
+const recordVisibilityToggles = async (reactGrab: ReactGrabPageObject): Promise => {
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ await reactGrab.page.click(TOGGLE_BUTTON_SELECTOR);
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+ await reactGrab.page.click(TOGGLE_BUTTON_SELECTOR);
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+};
+
+test.describe("Time Machine", () => {
+ test("Time Machine action is disabled before any state change", async ({ reactGrab }) => {
+ await reactGrab.activate();
+ await reactGrab.hoverUntilSelected(TOGGLE_BUTTON_SELECTOR);
+ await reactGrab.rightClickElement(TOGGLE_BUTTON_SELECTOR);
+ expect(await reactGrab.isContextMenuItemEnabled("Time Machine")).toBe(false);
+ });
+
+ test("toolbar Time Machine button opens the panel without selecting an element", async ({
+ reactGrab,
+ }) => {
+ await recordVisibilityToggles(reactGrab);
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ expect(await reactGrab.isOverlayVisible()).toBe(false);
+ expect(await getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+
+ await reactGrab.pressArrowLeft();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(false);
+ });
+
+ test("toolbar-opened panel keeps the app live and records new changes", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ await reactGrab.page.click(TOGGLE_BUTTON_SELECTOR);
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("3/3");
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ });
+
+ test("H shortcut opens the panel when history exists", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await reactGrab.activate();
+ await reactGrab.hoverUntilSelected(TOGGLE_BUTTON_SELECTOR);
+ await reactGrab.pressKey("h");
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ });
+
+ test("right-click -> Time Machine opens the panel after state changes", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+ expect(await isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ expect(await getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+ expect(await getTimeMachineClockText(reactGrab.page)).toBe("Now");
+ });
+
+ test("arrow keys travel backward and forward through state history", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+
+ await reactGrab.pressArrowLeft();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("1/2");
+
+ await reactGrab.pressArrowLeft();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("0/2");
+
+ await reactGrab.pressArrowRight();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("1/2");
+
+ await reactGrab.pressArrowRight();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+ });
+
+ test("stepping past either end of history is a no-op", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+
+ await reactGrab.pressArrowRight();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+
+ await reactGrab.pressArrowLeft();
+ await reactGrab.pressArrowLeft();
+ await reactGrab.pressArrowLeft();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("0/2");
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ });
+
+ test("timeline shows a lane per component and dragging it travels", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ const readTimelineInfo = () =>
+ reactGrab.page.evaluate((attrName) => {
+ const host = document.querySelector(`[${attrName}]`);
+ const timeline = host?.shadowRoot?.querySelector("[data-react-grab-time-machine-timeline]");
+ const laneArea = timeline?.querySelector("[role='slider']");
+ const laneAreaRect = laneArea?.getBoundingClientRect();
+ return {
+ laneLabels: [...(timeline?.querySelectorAll("span") ?? [])].map(
+ (label) => label.textContent,
+ ),
+ dotCount: timeline?.querySelectorAll("[data-react-grab-timeline-dot]").length ?? 0,
+ hasPlayhead: Boolean(timeline?.querySelector("[data-react-grab-timeline-playhead]")),
+ laneAreaRect: laneAreaRect
+ ? {
+ x: laneAreaRect.x,
+ y: laneAreaRect.y,
+ width: laneAreaRect.width,
+ height: laneAreaRect.height,
+ }
+ : null,
+ };
+ }, ATTRIBUTE_NAME);
+
+ const timelineInfo = await readTimelineInfo();
+ expect(timelineInfo.laneLabels).toEqual(["HiddenToggleSection"]);
+ expect(timelineInfo.dotCount).toBe(2);
+ expect(timelineInfo.hasPlayhead).toBe(true);
+ expect(timelineInfo.laneAreaRect).not.toBeNull();
+
+ // The panel animates in (translate + scale) and resizes as the position
+ // label changes, so coordinates are re-read immediately before each
+ // scrub instead of being computed once up front.
+ await expect
+ .poll(async () => {
+ const before = (await readTimelineInfo()).laneAreaRect;
+ await reactGrab.page.waitForTimeout(100);
+ const after = (await readTimelineInfo()).laneAreaRect;
+ return before && after && before.x === after.x && before.y === after.y;
+ })
+ .toBe(true);
+
+ const clickLaneAreaAt = async (widthRatio: number) => {
+ const laneAreaRect = (await readTimelineInfo()).laneAreaRect!;
+ await reactGrab.page.mouse.click(
+ laneAreaRect.x + laneAreaRect.width * widthRatio,
+ laneAreaRect.y + laneAreaRect.height / 2,
+ );
+ };
+
+ await clickLaneAreaAt(0.5);
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("1/2");
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+
+ await clickLaneAreaAt(0.99);
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ });
+
+ test("travels useReducer state on a component with a sibling useState", async ({ reactGrab }) => {
+ const incrementButton = reactGrab.page.locator("[data-testid='mixed-hooks-increment']");
+ const countText = reactGrab.page.locator("[data-testid='mixed-hooks-count']");
+ await incrementButton.scrollIntoViewIfNeeded();
+ await incrementButton.click();
+ await expect(countText).toHaveText("1");
+ await incrementButton.click();
+ await expect(countText).toHaveText("2");
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ await reactGrab.pressArrowLeft();
+ await expect(countText).toHaveText("1");
+ await reactGrab.pressArrowLeft();
+ await expect(countText).toHaveText("0");
+ await reactGrab.pressArrowRight();
+ await expect(countText).toHaveText("1");
+ await reactGrab.pressArrowRight();
+ await expect(countText).toHaveText("2");
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+ });
+
+ test("travels useReducer state on a reducer-only component", async ({ reactGrab }) => {
+ const incrementButton = reactGrab.page.locator("[data-testid='reducer-only-increment']");
+ const countText = reactGrab.page.locator("[data-testid='reducer-only-count']");
+ await incrementButton.scrollIntoViewIfNeeded();
+ await incrementButton.click();
+ await expect(countText).toHaveText("1");
+ await incrementButton.click();
+ await expect(countText).toHaveText("2");
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ await reactGrab.pressArrowLeft();
+ await expect(countText).toHaveText("1");
+ await reactGrab.pressArrowLeft();
+ await expect(countText).toHaveText("0");
+ await reactGrab.pressArrowRight();
+ await reactGrab.pressArrowRight();
+ await expect(countText).toHaveText("2");
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+ });
+
+ test("useSyncExternalStore changes are neither recorded nor travelled", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+
+ const storeButton = reactGrab.page.locator("[data-testid='external-store-increment']");
+ const storeCount = reactGrab.page.locator("[data-testid='external-store-count']");
+ await storeButton.scrollIntoViewIfNeeded();
+ await storeButton.click();
+ await expect(storeCount).toHaveText("1");
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ // Only the two visibility toggles were recorded; the external store
+ // change created no timeline entry.
+ expect(await getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+
+ await reactGrab.pressArrowLeft();
+ await reactGrab.pressArrowLeft();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ // Rewinding leaves external-store state untouched.
+ await expect(storeCount).toHaveText("1");
+ });
+
+ test("rewinding pins the hover styling captured with each change", async ({ reactGrab }) => {
+ const toggleButton = reactGrab.page.locator(TOGGLE_BUTTON_SELECTOR);
+ await toggleButton.scrollIntoViewIfNeeded();
+ // Real pointer hover so the recorded entries capture the button in
+ // their hover chain.
+ await toggleButton.hover();
+ await toggleButton.click();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+ await toggleButton.click();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ await reactGrab.page.mouse.move(0, 0);
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ const getPinnedPriority = () =>
+ reactGrab.page.evaluate((toggleSelector) => {
+ const button = document.querySelector(toggleSelector);
+ return button instanceof HTMLElement
+ ? button.style.getPropertyPriority("background-color")
+ : null;
+ }, TOGGLE_BUTTON_SELECTOR);
+
+ expect(await getPinnedPriority()).toBe("");
+
+ await reactGrab.pressArrowLeft();
+ await expect.poll(getPinnedPriority).toBe("important");
+
+ await reactGrab.pressArrowRight();
+ await expect.poll(getPinnedPriority).toBe("");
+ });
+
+ test("rewinding freezes page animations and returning to now resumes them", async ({
+ reactGrab,
+ }) => {
+ await recordVisibilityToggles(reactGrab);
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ expect(await getSpinnerAnimationPlayState(reactGrab.page)).toBe("running");
+
+ await reactGrab.pressArrowLeft();
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("paused");
+
+ await reactGrab.pressArrowRight();
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("running");
+ });
+
+ test("closing the panel while rewound lets animations flow again", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ await reactGrab.pressArrowLeft();
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("paused");
+
+ await reactGrab.pressEscape();
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(false);
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("running");
+ });
+
+ test("reopening while rewound re-freezes page animations", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+
+ await reactGrab.pressArrowLeft();
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("paused");
+
+ await reactGrab.pressEscape();
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(false);
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("running");
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("1/2");
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("paused");
+
+ await reactGrab.pressArrowRight();
+ await expect.poll(() => getSpinnerAnimationPlayState(reactGrab.page)).toBe("running");
+ });
+
+ test("Escape dismisses the panel and keeps the travelled state", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+
+ await reactGrab.pressArrowLeft();
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+
+ await reactGrab.pressEscape();
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(false);
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+ });
+
+ test("deactivating grab mode closes the panel", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+
+ await reactGrab.page.evaluate(() => {
+ window.__REACT_GRAB__?.deactivate();
+ });
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(false);
+ });
+
+ test("changes made after rewinding fork the timeline", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+
+ await reactGrab.pressArrowLeft();
+ await reactGrab.pressArrowLeft();
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("0/2");
+
+ await reactGrab.pressEscape();
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(false);
+
+ await reactGrab.page.click(TOGGLE_BUTTON_SELECTOR);
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("1/1");
+ });
+
+ test("Live pill grays out while rewound and returns to now on click", async ({ reactGrab }) => {
+ await recordVisibilityToggles(reactGrab);
+ await openTimeMachinePanel(reactGrab, TOGGLE_BUTTON_SELECTOR);
+
+ expect(await getLivePillState(reactGrab.page)).toBe("true");
+
+ await reactGrab.pressArrowLeft();
+ await expect.poll(() => getLivePillState(reactGrab.page)).toBe("false");
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeHidden();
+
+ await clickLivePill(reactGrab.page);
+ await expect.poll(() => getLivePillState(reactGrab.page)).toBe("true");
+ await expect.poll(() => getTimeMachineValueText(reactGrab.page)).toBe("2/2");
+ await expect.poll(() => getTimeMachineClockText(reactGrab.page)).toBe("Now");
+ await expect(reactGrab.page.locator(TOGGLEABLE_ELEMENT_SELECTOR)).toBeVisible();
+ });
+
+ test("a janky commit is flagged as a performance issue on the timeline", async ({
+ reactGrab,
+ }) => {
+ await reactGrab.page.click("[data-testid='janky-increment']");
+ await expect(reactGrab.page.locator("[data-testid='janky-count']")).toHaveText("1");
+
+ await clickToolbarTimeMachineButton(reactGrab);
+ await expect.poll(() => isTimeMachinePanelVisible(reactGrab.page)).toBe(true);
+ // Long-animation-frame entries deliver asynchronously after the janky
+ // frame ends; the slow-render flag covers profiling builds immediately.
+ await expect.poll(() => hasPerfFlaggedTimelineDot(reactGrab.page)).toBe(true);
+ });
+});
diff --git a/packages/react-grab/src/components/edit-panel/step-controller.ts b/packages/react-grab/src/components/edit-panel/step-controller.ts
index c22a00887..73256d1aa 100644
--- a/packages/react-grab/src/components/edit-panel/step-controller.ts
+++ b/packages/react-grab/src/components/edit-panel/step-controller.ts
@@ -3,6 +3,12 @@ import {
EDIT_STEP_REPEAT_INITIAL_DELAY_MS,
EDIT_STEP_REPEAT_INTERVAL_MS,
} from "../../constants.js";
+import {
+ nativeClearInterval,
+ nativeClearTimeout,
+ nativeSetInterval,
+ nativeSetTimeout,
+} from "../../utils/native-timers.js";
type ArrowKey = "ArrowLeft" | "ArrowRight";
type Direction = 1 | -1;
@@ -25,12 +31,14 @@ export interface StepController {
export const createStepController = (options: StepControllerOptions): StepController => {
const [heldDirection, setHeldDirection] = createSignal<-1 | 0 | 1>(0);
let pressedArrowKey: ArrowKey | null = null;
- let repeatInitialDelayId: ReturnType | null = null;
- let repeatIntervalId: ReturnType | null = null;
+ let repeatInitialDelayId: number | null = null;
+ let repeatIntervalId: number | null = null;
+ // Native timers, not window timers: hold-to-repeat must keep firing while
+ // the time machine's page-clock freeze suspends the page's own scheduling.
const clearRepeatTimers = () => {
- if (repeatInitialDelayId !== null) clearTimeout(repeatInitialDelayId);
- if (repeatIntervalId !== null) clearInterval(repeatIntervalId);
+ if (repeatInitialDelayId !== null) nativeClearTimeout(repeatInitialDelayId);
+ if (repeatIntervalId !== null) nativeClearInterval(repeatIntervalId);
repeatInitialDelayId = null;
repeatIntervalId = null;
pressedArrowKey = null;
@@ -45,8 +53,8 @@ export const createStepController = (options: StepControllerOptions): StepContro
clearRepeatTimers();
pressedArrowKey = key;
const direction = getDirectionForKey(key);
- repeatInitialDelayId = setTimeout(() => {
- repeatIntervalId = setInterval(() => {
+ repeatInitialDelayId = nativeSetTimeout(() => {
+ repeatIntervalId = nativeSetInterval(() => {
options.step(direction, options.isShiftHeld(), options.isAltHeld(), true);
}, EDIT_STEP_REPEAT_INTERVAL_MS);
}, EDIT_STEP_REPEAT_INITIAL_DELAY_MS);
diff --git a/packages/react-grab/src/components/icons/icon-history.tsx b/packages/react-grab/src/components/icons/icon-history.tsx
new file mode 100644
index 000000000..02c886297
--- /dev/null
+++ b/packages/react-grab/src/components/icons/icon-history.tsx
@@ -0,0 +1,27 @@
+import type { Component } from "solid-js";
+
+interface IconHistoryProps {
+ size?: number;
+ class?: string;
+}
+
+export const IconHistory: Component = (props) => {
+ const size = () => props.size ?? 14;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx
index 3edf99247..be350fd0a 100644
--- a/packages/react-grab/src/components/renderer.tsx
+++ b/packages/react-grab/src/components/renderer.tsx
@@ -9,6 +9,7 @@ import { SelectionLabel } from "./selection-label/index.js";
import { Toolbar } from "./toolbar/index.js";
import { ContextMenu } from "./context-menu.js";
import { EditPanel } from "./edit-panel/index.js";
+import { TimeMachinePanel } from "./time-machine-panel/index.js";
import { ToolbarMenu } from "./toolbar/toolbar-menu.js";
import { HierarchyMenu } from "./toolbar/hierarchy-menu.js";
@@ -157,6 +158,14 @@ export const ReactGrabRenderer: Component = (props) => {
onPendingEditsChange={props.onEditPanelPendingEditsChange}
onInteractingChange={props.onEditPanelInteractingChange}
/>
+ {})}
+ onDismiss={props.onTimeMachineDismiss ?? (() => {})}
+ />
>
);
};
diff --git a/packages/react-grab/src/components/time-machine-panel/index.tsx b/packages/react-grab/src/components/time-machine-panel/index.tsx
new file mode 100644
index 000000000..3438e2d01
--- /dev/null
+++ b/packages/react-grab/src/components/time-machine-panel/index.tsx
@@ -0,0 +1,325 @@
+import { createMemo, createSignal, onCleanup, onMount, Show, type Component } from "solid-js";
+import {
+ DROPDOWN_EDGE_TRANSFORM_ORIGIN,
+ EDIT_PANEL_ACTIVE_KEY_FLASH_MS,
+ EDIT_SHIFT_STEP_MULTIPLIER,
+ EDIT_SLIDER_SPRING_EASING,
+ EDIT_VALUE_BUMP_MS,
+ EDIT_VALUE_BUMP_PX,
+ TIME_MACHINE_PANEL_MAX_WIDTH_PX,
+ TIME_MACHINE_PANEL_MIN_WIDTH_PX,
+ Z_INDEX_OVERLAY,
+} from "../../constants.js";
+import type {
+ DropdownAnchor,
+ TimeMachinePanelState,
+ TimeMachineTimelineEntry,
+} from "../../types.js";
+import { cn } from "../../utils/cn.js";
+import { createAnchoredDropdown } from "../../utils/create-anchored-dropdown.js";
+import { formatEntryPerf } from "../../utils/format-entry-perf.js";
+import { formatRelativeTime } from "../../utils/format-relative-time.js";
+import { getTagDisplay } from "../../utils/get-tag-display.js";
+import { isEventFromOverlay } from "../../utils/is-event-from-overlay.js";
+import { isKeyboardEventTriggeredByInput } from "../../utils/is-keyboard-event-triggered-by-input.js";
+import { nativeClearTimeout, nativeSetTimeout } from "../../utils/native-timers.js";
+import { registerOverlayDismiss } from "../../utils/register-overlay-dismiss.js";
+import { suppressMenuEvent } from "../../utils/suppress-menu-event.js";
+import { createModifierTracker } from "../../utils/modifier-tracker.js";
+import { createStepController } from "../edit-panel/step-controller.js";
+import { ValueStepper } from "../edit-panel/value-stepper.js";
+import { TagBadge } from "../selection-label/tag-badge.js";
+import { Surface } from "../ui/surface.js";
+import { TimeMachineTimeline } from "./timeline.js";
+
+interface TimeMachinePanelProps {
+ state: TimeMachinePanelState | null;
+ position: DropdownAnchor | null;
+ entries: TimeMachineTimelineEntry[];
+ cursor: number;
+ onTravel: (cursor: number) => void;
+ onDismiss: () => void;
+}
+
+export const TimeMachinePanel: Component = (props) => (
+
+ {(state) => (
+ props.position}
+ entries={() => props.entries}
+ cursor={() => props.cursor}
+ onTravel={props.onTravel}
+ onDismiss={props.onDismiss}
+ />
+ )}
+
+);
+
+interface TimeMachinePanelBodyProps {
+ state: TimeMachinePanelState;
+ position: () => DropdownAnchor | null;
+ entries: () => TimeMachineTimelineEntry[];
+ cursor: () => number;
+ onTravel: (cursor: number) => void;
+ onDismiss: () => void;
+}
+
+const TimeMachinePanelBody: Component = (props) => {
+ let containerRef: HTMLDivElement | undefined;
+ let activeKeyTimerId: number | undefined;
+
+ const [activeKey, setActiveKey] = createSignal<"left" | "right" | null>(null);
+ const dropdown = createAnchoredDropdown(() => containerRef, props.position);
+
+ const totalEntries = () => props.entries().length;
+
+ const tagDisplay = createMemo(() =>
+ getTagDisplay({
+ tagName: props.state.tagName,
+ componentName: props.state.componentName,
+ }),
+ );
+
+ const currentEntry = createMemo(() => {
+ const cursor = props.cursor();
+ if (cursor === 0) return null;
+ return props.entries()[cursor - 1] ?? null;
+ });
+
+ const positionLabel = () => {
+ if (totalEntries() === 0) return "No changes yet";
+ return currentEntry()?.componentName ?? "Start";
+ };
+
+ const isLive = () => props.cursor() >= totalEntries();
+
+ const clockLabel = () => {
+ if (isLive()) return "Now";
+ const entry = currentEntry();
+ if (!entry) return "Start";
+ return formatRelativeTime(entry.timestamp);
+ };
+
+ const perfLabel = () => {
+ const entry = currentEntry();
+ return entry ? formatEntryPerf(entry) : null;
+ };
+
+ // Native timer: the flash must clear while the time machine's page-clock
+ // freeze suspends the page's own scheduling.
+ const flashActiveKey = (direction: "left" | "right") => {
+ setActiveKey(direction);
+ nativeClearTimeout(activeKeyTimerId);
+ activeKeyTimerId = nativeSetTimeout(() => {
+ setActiveKey((currentKey) => (currentKey === direction ? null : currentKey));
+ }, EDIT_PANEL_ACTIVE_KEY_FLASH_MS);
+ };
+
+ const travelBy = (direction: 1 | -1, shiftHeld: boolean) => {
+ const stepSize = shiftHeld ? EDIT_SHIFT_STEP_MULTIPLIER : 1;
+ props.onTravel(props.cursor() + direction * stepSize);
+ flashActiveKey(direction === 1 ? "right" : "left");
+ };
+
+ const stepFromKeyboard = (direction: 1 | -1, shiftHeld: boolean) => {
+ travelBy(direction, shiftHeld);
+ };
+
+ const stepFromPointer = (direction: 1 | -1) => {
+ travelBy(direction, false);
+ };
+
+ const isShiftHeld = createModifierTracker((event) => event.shiftKey);
+ const isAltHeld = createModifierTracker((event) => event.altKey);
+ const stepController = createStepController({ step: stepFromKeyboard, isShiftHeld, isAltHeld });
+
+ const playShake = () => {
+ if (!containerRef) return;
+ const surface = containerRef.firstElementChild;
+ if (!(surface instanceof HTMLElement)) return;
+ surface.classList.remove("animate-shake");
+ // Force reflow so re-adding the class restarts the animation.
+ void surface.offsetWidth;
+ surface.classList.add("animate-shake");
+ };
+
+ onMount(() => {
+ dropdown.measure();
+
+ const unregisterDismiss = registerOverlayDismiss({
+ isOpen: () => true,
+ onDismiss: (source) => {
+ // Opened from the toolbar (no element), the panel is a persistent
+ // utility over a live app: clicking the page interacts with it (and
+ // records new history) instead of dismissing. Escape or the toolbar
+ // button close it.
+ if (source === "pointer" && !props.state.element) return;
+ stepController.cancelRepeat();
+ props.onDismiss();
+ },
+ shouldIgnoreRightClick: true,
+ shouldIgnoreInputEvents: true,
+ });
+
+ const handleWindowKeyDown = (event: KeyboardEvent) => {
+ if (isEventFromOverlay(event, "data-react-grab-input")) return;
+ if (isKeyboardEventTriggeredByInput(event)) return;
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ stepController.pressArrow(event.key, event.repeat, event.shiftKey, event.altKey);
+ return;
+ }
+ if (event.key === "Enter") {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ stepController.cancelRepeat();
+ props.onDismiss();
+ }
+ };
+ const handleWindowKeyUp = (event: KeyboardEvent) => {
+ stepController.releaseKey(event.key);
+ };
+ window.addEventListener("keydown", handleWindowKeyDown, { capture: true });
+ window.addEventListener("keyup", handleWindowKeyUp, { capture: true });
+
+ onCleanup(() => {
+ unregisterDismiss();
+ window.removeEventListener("keydown", handleWindowKeyDown, { capture: true });
+ window.removeEventListener("keyup", handleWindowKeyUp, { capture: true });
+ nativeClearTimeout(activeKeyTimerId);
+ dropdown.clearAnimationHandles();
+ });
+ });
+
+ return (
+
+
+
+
+
+ Time Machine
+
+ }
+ >
+ {}}
+ shrink
+ />
+
+
+
+ {(label) => (
+
+ {label()}
+
+ )}
+
+
+ {clockLabel()}
+
+ {
+ if (isLive()) return;
+ props.onTravel(totalEntries());
+ }}
+ >
+
+ Live
+
+
+
+ 0}>
+
+
+ event.preventDefault()}
+ >
+ props.onTravel(value)}
+ onInvalidCommit={playShake}
+ emphasized
+ />
+
+
+
+
+ );
+};
diff --git a/packages/react-grab/src/components/time-machine-panel/timeline.tsx b/packages/react-grab/src/components/time-machine-panel/timeline.tsx
new file mode 100644
index 000000000..c9b472792
--- /dev/null
+++ b/packages/react-grab/src/components/time-machine-panel/timeline.tsx
@@ -0,0 +1,219 @@
+import { createMemo, createSignal, For, Index, type Component, type JSX } from "solid-js";
+import {
+ TIME_MACHINE_TIMELINE_ACTIVE_DOT_SIZE_PX,
+ TIME_MACHINE_TIMELINE_DOT_SIZE_PX,
+ TIME_MACHINE_TIMELINE_HASH_MARK_COUNT,
+ TIME_MACHINE_TIMELINE_LABEL_WIDTH_PX,
+ TIME_MACHINE_TIMELINE_LANE_HEIGHT_PX,
+ TIME_MACHINE_TIMELINE_MAX_TRACKS,
+ TIME_MACHINE_TIMELINE_PLAYHEAD_WIDTH_PX,
+} from "../../constants.js";
+import type { TimeMachineTimelineEntry } from "../../types.js";
+
+interface TimelineDot {
+ cursorPosition: number;
+ entryId: number;
+ laneIndex: number;
+ hasPerfIssue: boolean;
+}
+
+interface TimelineLane {
+ componentName: string;
+ laneIndex: number;
+}
+
+interface TimeMachineTimelineProps {
+ entries: TimeMachineTimelineEntry[];
+ cursor: number;
+ onTravel: (cursor: number) => void;
+ onInteract?: () => void;
+}
+
+const HASH_MARK_PERCENTS = Array.from(
+ { length: TIME_MACHINE_TIMELINE_HASH_MARK_COUNT },
+ (_, hashMarkIndex) => ((hashMarkIndex + 1) * 100) / (TIME_MACHINE_TIMELINE_HASH_MARK_COUNT + 1),
+);
+
+// A component timeline in the spirit of transitions.dev's Refine ruler: one
+// lane per component, its recorded changes as dots on a shared axis, and a
+// draggable playhead. The axis is entry-index based (not wall clock) so a
+// burst of changes doesn't collapse into an unscrubbable clump.
+export const TimeMachineTimeline: Component = (props) => {
+ let laneAreaRef: HTMLDivElement | undefined;
+ const [isDragging, setIsDragging] = createSignal(false);
+
+ const laneModel = createMemo<{ lanes: TimelineLane[]; dots: TimelineDot[] }>(() => {
+ const laneIndexByComponentName = new Map();
+ const lanes: TimelineLane[] = [];
+ const dots: TimelineDot[] = [];
+ let overflowLaneIndex = -1;
+
+ for (let entryIndex = 0; entryIndex < props.entries.length; entryIndex++) {
+ const entry = props.entries[entryIndex];
+ let laneIndex = laneIndexByComponentName.get(entry.componentName);
+ if (laneIndex === undefined) {
+ if (lanes.length < TIME_MACHINE_TIMELINE_MAX_TRACKS) {
+ laneIndex = lanes.length;
+ laneIndexByComponentName.set(entry.componentName, laneIndex);
+ lanes.push({ componentName: entry.componentName, laneIndex });
+ } else {
+ // Components beyond the lane budget share a trailing overflow lane
+ // so every change stays visible and scrubbable.
+ if (overflowLaneIndex === -1) {
+ overflowLaneIndex = lanes.length;
+ lanes.push({ componentName: "…", laneIndex: overflowLaneIndex });
+ }
+ laneIndex = overflowLaneIndex;
+ }
+ }
+ dots.push({
+ cursorPosition: entryIndex + 1,
+ entryId: entry.id,
+ laneIndex,
+ hasPerfIssue: entry.hasPerfIssue,
+ });
+ }
+
+ return { lanes, dots };
+ });
+
+ const positionPercent = (cursorPosition: number): number =>
+ props.entries.length === 0 ? 0 : (cursorPosition / props.entries.length) * 100;
+
+ const laneAreaHeightPx = () => laneModel().lanes.length * TIME_MACHINE_TIMELINE_LANE_HEIGHT_PX;
+
+ const travelToClientX = (clientX: number) => {
+ if (!laneAreaRef || props.entries.length === 0) return;
+ const laneAreaRect = laneAreaRef.getBoundingClientRect();
+ if (laneAreaRect.width <= 0) return;
+ const ratio = Math.max(0, Math.min(1, (clientX - laneAreaRect.left) / laneAreaRect.width));
+ props.onTravel(Math.round(ratio * props.entries.length));
+ };
+
+ const handlePointerDown: JSX.EventHandler = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setIsDragging(true);
+ props.onInteract?.();
+ travelToClientX(event.clientX);
+ };
+
+ const handlePointerMove: JSX.EventHandler = (event) => {
+ if (!isDragging()) return;
+ travelToClientX(event.clientX);
+ };
+
+ const releaseDrag: JSX.EventHandler = (event) => {
+ const target = event.currentTarget;
+ if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
+ setIsDragging(false);
+ };
+
+ return (
+
+
+
+ {(lane) => (
+
+ {lane.componentName}
+
+ )}
+
+
+
+
+
+ {(percent) => (
+
+ )}
+
+
+ {(dot) => {
+ const isApplied = () => dot.cursorPosition <= props.cursor;
+ const isAtPlayhead = () => dot.cursorPosition === props.cursor;
+ const dotSizePx = () =>
+ isAtPlayhead()
+ ? TIME_MACHINE_TIMELINE_ACTIVE_DOT_SIZE_PX
+ : TIME_MACHINE_TIMELINE_DOT_SIZE_PX;
+ const dotBackground = () => {
+ if (dot.hasPerfIssue) return "var(--rg-error-text)";
+ return isApplied() ? "var(--rg-text-primary)" : "var(--rg-text-secondary)";
+ };
+ return (
+
+ );
+ }}
+
+
+
+
+ );
+};
diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx
index 7cd5928c8..ebca15052 100644
--- a/packages/react-grab/src/components/toolbar/index.tsx
+++ b/packages/react-grab/src/components/toolbar/index.tsx
@@ -4,6 +4,7 @@ import { cn } from "../../utils/cn.js";
import { loadToolbarState, saveToolbarState, type SnapEdge, type ToolbarState } from "./state.js";
import { IconSelect } from "../icons/icon-select.jsx";
import { IconComment } from "../icons/icon-comment.jsx";
+import { IconHistory } from "../icons/icon-history.jsx";
import { IconStyle } from "../icons/icon-style.jsx";
import { ToolbarActionButton } from "./toolbar-action-button.jsx";
import {
@@ -19,6 +20,7 @@ import {
DEFAULT_ACTION_ID,
COMMENT_ACTION_ID,
EDIT_ACTION_ID,
+ TIME_MACHINE_ACTION_ID,
} from "../../constants.js";
import { freezeUpdates } from "../../utils/freeze-updates.js";
import {
@@ -157,11 +159,20 @@ export const Toolbar: Component = (props) => {
event.stopImmediatePropagation();
};
+ // While the time machine panel is open the app must stay live: time travel
+ // replays state through hook dispatchers, which the hover-time React
+ // updates freeze would buffer instead of committing.
+ const isTimeMachineOpen = () => props.activeActionId === TIME_MACHINE_ACTION_ID;
+
const createFreezeHandlers = (actionId: string, options?: FreezeHandlersOptions) => ({
onMouseEnter: (event: MouseEvent) => {
if (drag.isDragging()) return;
setHoveredActionId(actionId);
- if (options?.shouldFreezeInteractions !== false && !unfreezeUpdatesCallback) {
+ if (
+ options?.shouldFreezeInteractions !== false &&
+ !unfreezeUpdatesCallback &&
+ !isTimeMachineOpen()
+ ) {
unfreezeUpdatesCallback = freezeUpdates();
freezeGlobalInteractions(event.clientX, event.clientY);
}
@@ -193,8 +204,12 @@ export const Toolbar: Component = (props) => {
createEffect(
on(
- () => [props.isActive, props.isContextMenuOpen] as const,
- ([isActive, isContextMenuOpen]) => {
+ () => [props.isActive, props.isContextMenuOpen, isTimeMachineOpen()] as const,
+ ([isActive, isContextMenuOpen, isTimeMachinePanelOpen]) => {
+ if (isTimeMachinePanelOpen && unfreezeUpdatesCallback) {
+ releaseInteractionFreeze();
+ return;
+ }
if (!isActive && !isContextMenuOpen && unfreezeUpdatesCallback) {
unfreezeUpdatesCallback();
unfreezeUpdatesCallback = null;
@@ -284,6 +299,9 @@ export const Toolbar: Component = (props) => {
props.onActivateAction?.(COMMENT_ACTION_ID),
);
const handleStyle = drag.createDragAwareHandler(() => props.onActivateAction?.(EDIT_ACTION_ID));
+ const handleTimeMachine = drag.createDragAwareHandler(() =>
+ props.onActivateAction?.(TIME_MACHINE_ACTION_ID),
+ );
const actionButtonClass =
"group contain-layout flex items-center justify-center cursor-pointer interactive-scale a11y-hitbox";
@@ -792,6 +810,24 @@ export const Toolbar: Component = (props) => {
tooltipPosition={tooltipPosition()}
tooltip="Style"
/>
+
+ }
+ tooltipVisible={isTooltipVisible(TIME_MACHINE_ACTION_ID)}
+ tooltipPosition={tooltipPosition()}
+ tooltip="Time Machine"
+ />
>
}
/>
diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts
index fdb16b94e..bae7829e4 100644
--- a/packages/react-grab/src/constants.ts
+++ b/packages/react-grab/src/constants.ts
@@ -205,6 +205,7 @@ export const TOOLBAR_DEFAULT_POSITION_RATIO = 0.5;
export const DEFAULT_ACTION_ID = "copy";
export const COMMENT_ACTION_ID = "comment";
export const EDIT_ACTION_ID = "edit";
+export const TIME_MACHINE_ACTION_ID = "time-machine";
export const TOOLTIP_DELAY_MS = 400;
export const TOOLTIP_GRACE_PERIOD_MS = 800;
@@ -327,6 +328,54 @@ export const TAILWIND_SPACING_UNIT_PX = 4;
export const PIXELS_PER_REM = 16;
+export const TIME_MACHINE_MAX_ENTRIES = 200;
+export const TIME_MACHINE_PANEL_MIN_WIDTH_PX = 280;
+export const TIME_MACHINE_PANEL_MAX_WIDTH_PX = 360;
+export const TIME_MACHINE_TIMELINE_MAX_TRACKS = 4;
+export const TIME_MACHINE_TIMELINE_LANE_HEIGHT_PX = 16;
+export const TIME_MACHINE_TIMELINE_DOT_SIZE_PX = 5;
+export const TIME_MACHINE_TIMELINE_ACTIVE_DOT_SIZE_PX = 7;
+export const TIME_MACHINE_TIMELINE_LABEL_WIDTH_PX = 76;
+export const TIME_MACHINE_TIMELINE_PLAYHEAD_WIDTH_PX = 2;
+export const TIME_MACHINE_TIMELINE_HASH_MARK_COUNT = 3;
+export const TIME_MACHINE_TRAVEL_EXPECTATION_TTL_MS = 1000;
+// Travel state commits flush asynchronously and remounted elements start
+// their CSS animations on the frame after insertion, so newborn animations
+// are swept for over a few frames after each travel step.
+export const TIME_MACHINE_ANIMATION_SETTLE_SWEEP_FRAMES = 3;
+export const TIME_MACHINE_MAX_INTERACTION_ELEMENTS = 24;
+// Animation-driven state (text scrambles, count-ups, springs) commits on
+// every tick — often across many small component instances (e.g. one per
+// text grapheme). Consecutive commits within this rolling window coalesce
+// into one timeline entry, so a timeline position is always a quiet-period
+// "settled moment" and scrubbing never lands on a transient mid-animation
+// frame. The window sits above animation tick cadence (16-70ms staggers)
+// but below deliberate human double-interaction speed (~150ms+).
+export const TIME_MACHINE_COALESCE_WINDOW_MS = 120;
+// Text-morph/exit-transition libraries commit once to START a transition and
+// once more to clean up when it FINISHES a few hundred ms later; landing
+// between the two shows both the old and new content overlapping. A commit
+// that only touches hook queues already changed by the previous entry is
+// such a settling commit — never a new interaction — so it coalesces across
+// this longer window, keyed on queue identity so a distinct user action on
+// another control is never swallowed.
+export const TIME_MACHINE_SETTLE_COALESCE_WINDOW_MS = 700;
+// A commit this close after a pointer/keyboard event is user-driven, and
+// user-driven commits never coalesce — two quick clicks on the same toggle
+// must stay two scrub steps. Only ambient commits (timers, animation loops,
+// network) are burst/settle candidates.
+export const TIME_MACHINE_INPUT_ATTRIBUTION_WINDOW_MS = 200;
+// One 60fps frame budget: an entry whose commit spent longer than this
+// rendering is flagged as a performance issue on the timeline. React only
+// populates render durations in profiling builds (default-on in dev), so
+// plain production builds report 0 and never false-flag.
+export const TIME_MACHINE_SLOW_RENDER_THRESHOLD_MS = 16;
+// Covers the drift between an entry's Date.now() timestamp and a
+// long-animation-frame's performance.timeOrigin-based window when deciding
+// whether the entry's commit happened inside that frame.
+export const TIME_MACHINE_LOAF_ATTRIBUTION_SLACK_MS = 50;
+export const LONG_ANIMATION_FRAME_ENTRY_TYPE = "long-animation-frame";
+
export const IME_COMPOSING_KEY_CODE = 229;
export const SELECTION_LABEL_OFFSCREEN_PX = -9999;
export const SHIFT_SELECTION_LABEL_MIN_ANCHOR_RATIO = 0;
diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx
index 263068fbf..9b82d61a9 100644
--- a/packages/react-grab/src/core/index.tsx
+++ b/packages/react-grab/src/core/index.tsx
@@ -89,6 +89,7 @@ import {
DEFAULT_ACTION_ID,
COMMENT_ACTION_ID,
EDIT_ACTION_ID,
+ TIME_MACHINE_ACTION_ID,
REACT_GRAB_ATTRIBUTE_NAME,
REACT_GRAB_INPUT_ATTRIBUTE,
} from "../constants.js";
@@ -139,6 +140,9 @@ import { copyPlugin } from "./plugins/copy.js";
import { commentPlugin } from "./plugins/comment.js";
import { editPlugin } from "./plugins/edit.js";
import { openPlugin } from "./plugins/open.js";
+import { timeMachinePlugin } from "./plugins/time-machine.js";
+import { stopTimeMachineRecorder } from "./time-machine-recorder.js";
+import { createTimeMachineController, type TimeMachineTriggerOptions } from "./time-machine.js";
import { freezeAnimations, freezeAllAnimations } from "../utils/freeze-animations.js";
import {
freezeGlobalInteractions,
@@ -149,9 +153,10 @@ import { generateId } from "../utils/generate-id.js";
import { logRecoverableError } from "../utils/log-recoverable-error.js";
import { getNearestEdge } from "../utils/get-nearest-edge.js";
import { findShortcutAction } from "../utils/action-shortcuts.js";
+import { resolveActionEnabled } from "../utils/resolve-action-enabled.js";
import { createKeyboardSelectionController } from "./keyboard-selection.js";
-const builtInPlugins = [copyPlugin, editPlugin, commentPlugin, openPlugin];
+const builtInPlugins = [copyPlugin, editPlugin, commentPlugin, openPlugin, timeMachinePlugin];
interface CopyWithLabelOptions {
element: Element;
@@ -353,8 +358,30 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
},
});
+ const [timeMachinePosition, setTimeMachinePosition] = createSignal(null);
+ const timeMachine = createTimeMachineController({
+ store,
+ actions,
+ isActivated,
+ activateRenderer: () => activateRenderer(),
+ deactivateRenderer: () => deactivateRenderer(),
+ onOpen: () => {
+ dismissToolbarMenu();
+ stopTimeMachineTracking?.();
+ stopTimeMachineTracking = trackDropdownPosition(
+ computeTimeMachineAnchor,
+ setTimeMachinePosition,
+ );
+ },
+ onClose: () => {
+ stopTimeMachineTracking?.();
+ stopTimeMachineTracking = null;
+ setTimeMachinePosition(null);
+ },
+ });
+
const isModalPopoverOpen = createMemo(
- () => store.contextMenuPosition !== null || editMode.isOpen(),
+ () => store.contextMenuPosition !== null || editMode.isOpen() || timeMachine.isOpen(),
);
const isAnyPopoverOpen = createMemo(
() => isModalPopoverOpen() || toolbarMenuPosition() !== null,
@@ -362,6 +389,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
let toolbarElement: HTMLDivElement | undefined;
let stopToolbarMenuTracking: (() => void) | null = null;
let stopEditPanelTracking: (() => void) | null = null;
+ let stopTimeMachineTracking: (() => void) | null = null;
let didSwitchEditTargetOnPointerDown = false;
let shiftSelectionLabelAnchorRatioByElement = new WeakMap();
@@ -542,6 +570,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
);
const toolbarActiveActionId = createMemo(() => {
if (editMode.isOpen()) return EDIT_ACTION_ID;
+ if (timeMachine.isOpen()) return TIME_MACHINE_ACTION_ID;
if (isCommentMode()) return COMMENT_ACTION_ID;
if (isPendingContextMenuSelect()) return pendingToolbarActionId();
if (isActivated()) return DEFAULT_ACTION_ID;
@@ -951,9 +980,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
onCleanup(cleanup);
});
+ // Time travel replays state through each hook's queue.dispatch, which the
+ // React-updates freeze would buffer until deactivation — so the freeze is
+ // suspended while the time machine panel is open.
+ const shouldFreezeReactUpdates = createMemo(() => isActivated() && !timeMachine.isOpen());
+
createEffect(
- on(isActivated, (activated) => {
- if (!activated) return;
+ on(shouldFreezeReactUpdates, (shouldFreeze) => {
+ if (!shouldFreeze) return;
if (!pluginRegistry.store.options.freezeReactUpdates) return;
const unfreezeUpdates = freezeUpdates();
onCleanup(unfreezeUpdates);
@@ -1444,6 +1478,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
stopSpaceDragRepositioning();
actions.deactivate();
editMode.resetWithDiscard();
+ timeMachine.reset();
dismissToolbarMenu();
stopShiftMultiSelecting();
clearKeyboardNavigation();
@@ -1551,6 +1586,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
editMode.dismiss();
return;
}
+ if (timeMachine.isOpen()) {
+ timeMachine.dismiss();
+ return;
+ }
const element = store.frozenElement || targetElement();
if (!element) return;
openEditMode(element, { x: pointer().x, y: pointer().y });
@@ -1562,6 +1601,30 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
overrides: EditModeOverrides = {},
): boolean => editMode.trigger(element, position, overrides);
+ const openTimeMachine = (
+ position: Position,
+ options: TimeMachineTriggerOptions = {},
+ ): boolean => timeMachine.trigger(position, options);
+
+ // The toolbar opens history as a selection-free utility: the panel
+ // anchors to the toolbar and the app keeps running live (no activation,
+ // no freeze), so the timeline can even be watched filling up.
+ const toggleToolbarTimeMachine = () => {
+ if (timeMachine.isOpen()) {
+ timeMachine.closePreservingRenderer();
+ return;
+ }
+ if (!isEnabled()) return;
+ actions.hideContextMenu();
+ dismissToolbarMenu();
+ if (editMode.isOpen()) editMode.closePreservingRenderer();
+ const anchor = computeDropdownAnchor();
+ openTimeMachine({
+ x: anchor?.x ?? window.innerWidth / 2,
+ y: anchor?.y ?? window.innerHeight / 2,
+ });
+ };
+
const tryHandleEditModeElementSwitch = (clientX: number, clientY: number): boolean => {
if (!editMode.isOpen() || store.contextMenuPosition !== null) return false;
const element = getElementsAtPoint(clientX, clientY).find(isValidGrabbableElement);
@@ -1619,6 +1682,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
};
const handleActivateAction = (actionId: string) => {
+ if (actionId === TIME_MACHINE_ACTION_ID) {
+ toggleToolbarTimeMachine();
+ return;
+ }
if (isActivated()) {
// While still choosing an element, clicking a different action switches
// the pending action in place instead of tearing down selection mode;
@@ -2328,6 +2395,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
if (!shortcut) return false;
const { element, action } = shortcut;
+ const position = { x: pointer().x, y: pointer().y };
+ const actionContext = buildImmediateActionContext(element, position);
+ // A disabled action (e.g. History before any state change) must not
+ // swallow the key from the host app.
+ if (!resolveActionEnabled(action, actionContext)) return false;
+
if (isPromptMode()) {
if (!runActionForCurrentSelection(action.id)) return false;
event.preventDefault();
@@ -2335,8 +2408,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
return true;
}
- const position = { x: pointer().x, y: pointer().y };
- action.onAction(buildImmediateActionContext(element, position));
+ action.onAction(actionContext);
event.preventDefault();
event.stopImmediatePropagation();
@@ -2370,6 +2442,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
if (isCopying()) return false;
if (store.contextMenuPosition !== null) return false;
if (editMode.isOpen()) return false;
+ if (timeMachine.isOpen()) return false;
const isShiftF10 = event.key === "F10" && event.shiftKey;
const isContextMenuKey = event.key === "ContextMenu";
@@ -3528,6 +3601,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
hideContextMenuAction();
};
+ const enterTimeMachineAction = () => {
+ const didOpen = openTimeMachine(position, {
+ element,
+ componentName,
+ tagName,
+ });
+ if (didOpen) {
+ clearPendingToolbarSelection();
+ }
+ hideContextMenuAction();
+ };
+
const context: ContextMenuActionContext = {
element,
elements,
@@ -3537,6 +3622,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
tagName,
enterPromptMode: customEnterPromptMode ?? defaultEnterPromptMode,
enterEditMode: enterEditModeAction,
+ enterTimeMachine: enterTimeMachineAction,
copy: copyAction,
hooks: {
transformHtmlContent: pluginRegistry.hooks.transformHtmlContent,
@@ -3630,6 +3716,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
};
};
+ const computeTimeMachineAnchor = (): DropdownAnchor | null => {
+ const toolbarAnchor = computeDropdownAnchor();
+ if (toolbarAnchor) return toolbarAnchor;
+ const state = timeMachine.state();
+ if (!state) return null;
+ return {
+ x: state.position.x,
+ y: state.position.y,
+ edge: "bottom",
+ };
+ };
+
// Keep sibling dropdown tracking independent; sharing one RAF id breaks anchoring.
const trackDropdownPosition = (
getAnchor: () => DropdownAnchor | null,
@@ -3672,6 +3770,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
actions.hideContextMenu();
dismissToolbarMenu();
editMode.dismiss();
+ timeMachine.dismiss();
};
const handleToggleToolbarMenu = () => {
@@ -3680,6 +3779,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
} else {
actions.hideContextMenu();
if (editMode.isOpen()) editMode.closePreservingRenderer();
+ if (timeMachine.isOpen()) timeMachine.closePreservingRenderer();
stopToolbarMenuTracking?.();
stopToolbarMenuTracking = trackDropdownPosition(
computeDropdownAnchor,
@@ -3849,6 +3949,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
onEditPanelSubmit={editMode.submit}
onEditPanelPendingEditsChange={editMode.setPendingEdits}
onEditPanelInteractingChange={editMode.setInteracting}
+ timeMachineState={timeMachine.state()}
+ timeMachinePosition={timeMachinePosition()}
+ timeMachineEntries={timeMachine.entries()}
+ timeMachineCursor={timeMachine.cursor()}
+ onTimeMachineTravel={timeMachine.travelTo}
+ onTimeMachineDismiss={timeMachine.dismiss}
/>
);
}, rendererRoot);
@@ -3937,11 +4043,19 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
dispose: () => {
disposed = true;
hasInited = false;
+ // The recorder's timeline, panel-open flag, and frozen animation
+ // clock are module state that outlives this Solid root — without
+ // this, disposing mid-rewind leaves the page's animations frozen
+ // and a re-init inherits a stale timeline.
+ timeMachine.reset();
+ stopTimeMachineRecorder();
disposeRenderer?.();
stopToolbarMenuTracking?.();
stopToolbarMenuTracking = null;
stopEditPanelTracking?.();
stopEditPanelTracking = null;
+ stopTimeMachineTracking?.();
+ stopTimeMachineTracking = null;
toolbarStateChangeCallbacks.clear();
dispose();
},
diff --git a/packages/react-grab/src/core/plugins/time-machine.ts b/packages/react-grab/src/core/plugins/time-machine.ts
new file mode 100644
index 000000000..06b0bfb3b
--- /dev/null
+++ b/packages/react-grab/src/core/plugins/time-machine.ts
@@ -0,0 +1,36 @@
+import { TIME_MACHINE_ACTION_ID } from "../../constants.js";
+import type { Plugin } from "../../types.js";
+import { IS_DEMO } from "../../utils/runtime-mode.js";
+import {
+ hasTimeMachineHistory,
+ startTimeMachineRecorder,
+ stopTimeMachineRecorder,
+} from "../time-machine-recorder.js";
+
+export const timeMachinePlugin: Plugin = {
+ name: "time-machine",
+ setup: () => {
+ // The demo build is a display-only showcase scoped to one container;
+ // 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 {
+ actions: [
+ {
+ id: TIME_MACHINE_ACTION_ID,
+ label: "Time Machine",
+ shortcut: "H",
+ shortcutModifier: false,
+ showInToolbarMenu: true,
+ enabled: () => hasTimeMachineHistory(),
+ onAction: (context) => {
+ context.enterTimeMachine?.();
+ },
+ },
+ ],
+ cleanup: stopTimeMachineRecorder,
+ };
+ },
+};
diff --git a/packages/react-grab/src/core/time-machine-animation-clock.ts b/packages/react-grab/src/core/time-machine-animation-clock.ts
new file mode 100644
index 000000000..a6b857fda
--- /dev/null
+++ b/packages/react-grab/src/core/time-machine-animation-clock.ts
@@ -0,0 +1,197 @@
+// Rewinding state without rewinding motion looks broken: spinners keep
+// spinning, remounted elements replay their entry animations, and CSS
+// transitions tween between scrub steps. This module stops the page's
+// animation clock while the time machine is rewound. Instead of snapshotting
+// animation times on every commit (document.getAnimations() forces a style
+// flush, too expensive per commit), the clock captures each animation's
+// currentTime once — at the moment of the first rewind — and derives its time
+// at any history entry arithmetically from the entry's timestamp:
+// entryTime = capturedTime - (captureWallClock - entryWallClock).
+import { TIME_MACHINE_ANIMATION_SETTLE_SWEEP_FRAMES } from "../constants.js";
+import { createStyleElement } from "../utils/create-style-element.js";
+import { isShadowAnimation } from "../utils/freeze-animations.js";
+import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js";
+
+// Newborn CSS animations (from travel-remounted elements) must be born paused
+// or their first frames flash before the settle sweep can seek them; killing
+// transitions makes scrub steps snap instantly instead of tweening.
+const ANIMATION_CLOCK_STYLES = `
+*, *::before, *::after {
+ animation-play-state: paused !important;
+ transition: none !important;
+}
+`;
+
+interface FrozenAnimationRecord {
+ animation: Animation;
+ baseTimeMs: number;
+ didPause: boolean;
+}
+
+let clockFrozenAtMs: number | null = null;
+let clockTargetMs = 0;
+let frozenAnimationRecords: FrozenAnimationRecord[] = [];
+let recordByAnimation = new Map();
+let clockStyleElement: HTMLStyleElement | null = null;
+let settleFramesRemaining = 0;
+let settleFrameId: number | null = null;
+
+const readCurrentTimeMs = (animation: Animation): number | null => {
+ const currentTime: unknown = animation.currentTime;
+ return typeof currentTime === "number" ? currentTime : null;
+};
+
+const readEndTimeMs = (animation: Animation): number => {
+ try {
+ const endTime: unknown = animation.effect?.getComputedTiming().endTime;
+ return typeof endTime === "number" ? endTime : Number.POSITIVE_INFINITY;
+ } catch {
+ return Number.POSITIVE_INFINITY;
+ }
+};
+
+// Finite animations are one-shot transitions (enter/exit/hover tweens); an
+// entry recorded at the commit that TRIGGERED such a transition would freeze
+// it mid-flight — e.g. a text swap showing both the old and new text at
+// once, which reads as garbage. Their truthful "between moments" look is the
+// settled end pose, so finite animations park there while rewound. Only
+// infinite (looping) animations get wall-clock seeking, which is what makes
+// spinners visibly turn backward as you scrub.
+const seekAnimation = (record: FrozenAnimationRecord): void => {
+ const frozenAtMs = clockFrozenAtMs;
+ if (frozenAtMs === null) return;
+ const endTimeMs = readEndTimeMs(record.animation);
+ const elapsedSinceEntryMs = frozenAtMs - clockTargetMs;
+ try {
+ record.animation.currentTime = Number.isFinite(endTimeMs)
+ ? endTimeMs
+ : Math.max(0, record.baseTimeMs - elapsedSinceEntryMs);
+ } catch {
+ // The animation was cancelled or its target detached mid-rewind.
+ }
+};
+
+const captureAnimation = (animation: Animation): void => {
+ const currentTimeMs = readCurrentTimeMs(animation);
+ if (currentTimeMs === null) return;
+ const didPause = animation.playState === "running";
+ if (didPause) {
+ try {
+ animation.pause();
+ } catch {
+ return;
+ }
+ }
+ const record: FrozenAnimationRecord = { animation, baseTimeMs: currentTimeMs, didPause };
+ frozenAnimationRecords.push(record);
+ recordByAnimation.set(animation, record);
+ seekAnimation(record);
+};
+
+// An animation first seen while rewound comes from a travel-remounted
+// element, which at the rewound moment had long settled — so its entry
+// animation is held at its final pose instead of replaying. Loops have no
+// final pose; any frame reads as "frozen in time".
+const captureNewbornAnimation = (animation: Animation): void => {
+ if (animation.playState === "running") {
+ try {
+ animation.pause();
+ } catch {
+ return;
+ }
+ }
+ const endTimeMs = readEndTimeMs(animation);
+ const restingTimeMs = Number.isFinite(endTimeMs) ? endTimeMs : 0;
+ try {
+ animation.currentTime = restingTimeMs;
+ } catch {
+ return;
+ }
+ const record: FrozenAnimationRecord = {
+ animation,
+ baseTimeMs: restingTimeMs,
+ didPause: true,
+ };
+ frozenAnimationRecords.push(record);
+ recordByAnimation.set(animation, record);
+};
+
+const sweepAnimations = (): void => {
+ for (const animation of document.getAnimations()) {
+ if (isShadowAnimation(animation)) continue;
+ if (recordByAnimation.has(animation)) continue;
+ captureNewbornAnimation(animation);
+ }
+};
+
+const runSettleSweep = (): void => {
+ settleFrameId = null;
+ if (clockFrozenAtMs === null || settleFramesRemaining === 0) return;
+ settleFramesRemaining -= 1;
+ sweepAnimations();
+ if (settleFramesRemaining > 0) {
+ settleFrameId = nativeRequestAnimationFrame(runSettleSweep);
+ }
+};
+
+const scheduleSettleSweep = (): void => {
+ settleFramesRemaining = TIME_MACHINE_ANIMATION_SETTLE_SWEEP_FRAMES;
+ if (settleFrameId === null) {
+ settleFrameId = nativeRequestAnimationFrame(runSettleSweep);
+ }
+};
+
+const freezeClock = (): void => {
+ if (clockFrozenAtMs !== null) return;
+ clockFrozenAtMs = Date.now();
+ // READ before WRITE: getAnimations() flushes styles, so it runs before the
+ // stylesheet injection to avoid a second full-document recalc.
+ const animations = document.getAnimations();
+ clockStyleElement = createStyleElement("data-react-grab-time-machine-clock-freeze", "");
+ clockStyleElement.textContent = ANIMATION_CLOCK_STYLES;
+ for (const animation of animations) {
+ if (isShadowAnimation(animation)) continue;
+ captureAnimation(animation);
+ }
+};
+
+// Points the frozen clock at a history entry's wall-clock moment. Idempotent
+// per scrub step; freezes the clock on the first rewound step.
+export const syncAnimationClock = (entryTimestampMs: number): void => {
+ freezeClock();
+ clockTargetMs = entryTimestampMs;
+ for (const record of frozenAnimationRecords) {
+ seekAnimation(record);
+ }
+ scheduleSettleSweep();
+};
+
+// Returning to the present (or closing the panel) restores every animation to
+// the time it was captured at and resumes the ones the clock paused — a
+// seamless continuation, unlike finish(), because nothing moved while frozen.
+export const releaseAnimationClock = (): void => {
+ if (clockFrozenAtMs === null) return;
+ clockFrozenAtMs = null;
+ settleFramesRemaining = 0;
+ if (settleFrameId !== null) {
+ nativeCancelAnimationFrame(settleFrameId);
+ settleFrameId = null;
+ }
+ // The pause stylesheet goes first so play() below isn't fighting the
+ // universal animation-play-state rule; the transition:none rule also masks
+ // any style snap from the restored seeks.
+ clockStyleElement?.remove();
+ clockStyleElement = null;
+ for (const record of frozenAnimationRecords) {
+ try {
+ record.animation.currentTime = record.baseTimeMs;
+ if (record.didPause) {
+ record.animation.play();
+ }
+ } catch {
+ // The animation was cancelled or its target detached mid-rewind.
+ }
+ }
+ frozenAnimationRecords = [];
+ recordByAnimation = new Map();
+};
diff --git a/packages/react-grab/src/core/time-machine-interaction-snapshot.ts b/packages/react-grab/src/core/time-machine-interaction-snapshot.ts
new file mode 100644
index 000000000..3af8a20e9
--- /dev/null
+++ b/packages/react-grab/src/core/time-machine-interaction-snapshot.ts
@@ -0,0 +1,137 @@
+// Hover/focus/active visuals can only be read while the pseudo-class is
+// genuinely active, so each timeline entry captures them at record time —
+// the moment the interaction actually happened — as computed-value pins.
+// While rewound, the pins of the current entry are applied as !important
+// inline styles (the same technique freeze-pseudo-states uses for the grab
+// freeze), so a button that was hovered when a change was recorded lights up
+// again when you scrub back to that moment. Focus is never actually moved
+// and no synthetic events fire; this is purely visual.
+import { TIME_MACHINE_MAX_INTERACTION_ELEMENTS } from "../constants.js";
+import {
+ collectFocusedElements,
+ FOCUS_STYLE_PROPERTIES,
+ HOVER_STYLE_PROPERTIES,
+} from "../utils/freeze-pseudo-states.js";
+import { REACT_GRAB_ATTRIBUTE_NAME } from "../utils/react-grab-attribute-name.js";
+
+interface InteractionStylePin {
+ elementRef: WeakRef;
+ properties: readonly string[];
+ computedValues: string[];
+}
+
+export interface TimeMachineInteractionSnapshot {
+ pins: InteractionStylePin[];
+}
+
+interface AppliedPinRecord {
+ element: HTMLElement;
+ property: string;
+ pinnedValue: string;
+ originalValue: string;
+ originalPriority: string;
+}
+
+let appliedPinRecords: AppliedPinRecord[] = [];
+
+const isOverlayElement = (element: HTMLElement): boolean =>
+ element.hasAttribute(REACT_GRAB_ATTRIBUTE_NAME);
+
+const collectPseudoClassElements = (pseudoClassSelector: string): HTMLElement[] => {
+ try {
+ return Array.from(document.querySelectorAll(pseudoClassSelector)).filter(
+ (element): element is HTMLElement =>
+ element instanceof HTMLElement && !isOverlayElement(element),
+ );
+ } catch {
+ return [];
+ }
+};
+
+const captureElementPin = (
+ element: HTMLElement,
+ properties: readonly string[],
+): InteractionStylePin => {
+ const computed = getComputedStyle(element);
+ const computedValues: string[] = [];
+ for (const property of properties) {
+ computedValues.push(computed.getPropertyValue(property));
+ }
+ return { elementRef: new WeakRef(element), properties, computedValues };
+};
+
+// READ phase per recorded commit: getComputedStyle flushes styles, but only
+// for the handful of elements in the hover/focus/active chains, and a flush
+// was imminent anyway for the just-committed frame to paint.
+export const captureInteractionSnapshot = (): TimeMachineInteractionSnapshot | null => {
+ const pins: InteractionStylePin[] = [];
+ const pinnedElements = new Set();
+
+ const capture = (elements: HTMLElement[], properties: readonly string[]) => {
+ for (const element of elements) {
+ if (pins.length >= TIME_MACHINE_MAX_INTERACTION_ELEMENTS) return;
+ if (pinnedElements.has(element)) continue;
+ pinnedElements.add(element);
+ pins.push(captureElementPin(element, properties));
+ }
+ };
+
+ capture(collectPseudoClassElements(":active"), HOVER_STYLE_PROPERTIES);
+ capture(collectPseudoClassElements(":hover"), HOVER_STYLE_PROPERTIES);
+ capture(
+ collectFocusedElements().filter((element) => !isOverlayElement(element)),
+ FOCUS_STYLE_PROPERTIES,
+ );
+
+ return pins.length > 0 ? { pins } : null;
+};
+
+// A pin is only unwound when it still owns the property — travel re-renders
+// may legitimately overwrite inline styles while rewound, and restoring on
+// top of those would clobber newer app-authored values.
+const unpinRecord = (record: AppliedPinRecord): void => {
+ const { element, property } = record;
+ if (
+ element.style.getPropertyValue(property) !== record.pinnedValue ||
+ element.style.getPropertyPriority(property) !== "important"
+ ) {
+ return;
+ }
+ if (record.originalValue) {
+ element.style.setProperty(property, record.originalValue, record.originalPriority);
+ } else {
+ element.style.removeProperty(property);
+ }
+};
+
+export const releaseInteractionPins = (): void => {
+ for (const record of appliedPinRecords) {
+ unpinRecord(record);
+ }
+ appliedPinRecords = [];
+};
+
+// Swaps the applied pins to the given entry's snapshot (or clears them for
+// null, e.g. the position before the first recorded change).
+export const applyInteractionSnapshot = (snapshot: TimeMachineInteractionSnapshot | null): void => {
+ releaseInteractionPins();
+ if (!snapshot) return;
+
+ for (const pin of snapshot.pins) {
+ const element = pin.elementRef.deref();
+ if (!element || !element.isConnected) continue;
+ for (let propertyIndex = 0; propertyIndex < pin.properties.length; propertyIndex++) {
+ const property = pin.properties[propertyIndex];
+ const pinnedValue = pin.computedValues[propertyIndex];
+ if (!pinnedValue) continue;
+ appliedPinRecords.push({
+ element,
+ property,
+ pinnedValue,
+ originalValue: element.style.getPropertyValue(property),
+ originalPriority: element.style.getPropertyPriority(property),
+ });
+ element.style.setProperty(property, pinnedValue, "important");
+ }
+ }
+};
diff --git a/packages/react-grab/src/core/time-machine-page-clock.ts b/packages/react-grab/src/core/time-machine-page-clock.ts
new file mode 100644
index 000000000..b5c560d49
--- /dev/null
+++ b/packages/react-grab/src/core/time-machine-page-clock.ts
@@ -0,0 +1,126 @@
+// Freezing WAAPI/CSS animations is not enough to stop a page's motion: apps
+// also animate through requestAnimationFrame loops, setTimeout chains, and
+// setInterval tickers (text scramblers, typewriters, carousels), all of which
+// keep mutating state and DOM while the time machine sits rewound — scrubbing
+// then fights a live animation engine and the page flickers between recorded
+// and freshly-animated frames. This module suspends the page's scheduling
+// clock while rewound.
+//
+// Interception wraps callbacks at schedule time and decides at FIRE time
+// (mirroring freeze-gsap's approach): self-rescheduling loops started before
+// interception get caught on their next iteration. While frozen, rAF and
+// timeout callbacks park (replayed in order on release) and interval ticks
+// drop (a frozen clock doesn't owe missed ticks). React's work loop schedules
+// through MessageChannel, so travel commits still flush while frozen, and
+// react-grab's own UI schedules through native-raf/native-timers, which
+// bypass these wrappers.
+import { registerUnwrappedTimers } from "../utils/native-timers.js";
+
+let isPageClockFrozen = false;
+let isInstalled = false;
+
+const parkedRafCallbacks = new Map();
+const parkedTimeoutCallbacks = new Map void>();
+
+export const installPageClockInterception = (): void => {
+ if (isInstalled || typeof window === "undefined") return;
+ isInstalled = true;
+
+ // requestAnimationFrame may already be wrapped by freeze-gsap (installed at
+ // its module load); capturing the current implementations composes the two.
+ const previousRaf = window.requestAnimationFrame.bind(window);
+ const previousCancelRaf = window.cancelAnimationFrame.bind(window);
+ const previousSetTimeout = window.setTimeout.bind(window);
+ const previousClearTimeout = window.clearTimeout.bind(window);
+ const previousSetInterval = window.setInterval.bind(window);
+ const previousClearInterval = window.clearInterval.bind(window);
+
+ // Hands react-grab's own UI (which loads later, in the lazy renderer
+ // chunk) a freeze-bypassing path — its timers must keep firing while the
+ // page's clock is suspended during a rewind.
+ registerUnwrappedTimers({
+ setTimeout: previousSetTimeout,
+ clearTimeout: previousClearTimeout,
+ setInterval: previousSetInterval,
+ clearInterval: previousClearInterval,
+ });
+
+ window.requestAnimationFrame = (callback: FrameRequestCallback): number => {
+ let frameId = 0;
+ frameId = previousRaf((timestamp: DOMHighResTimeStamp) => {
+ if (isPageClockFrozen) {
+ parkedRafCallbacks.set(frameId, callback);
+ return;
+ }
+ // Matches the native invocation context — browsers call rAF and timer
+ // callbacks with `this === window`, and sloppy-mode page code relies
+ // on that.
+ callback.call(window, timestamp);
+ });
+ return frameId;
+ };
+
+ window.cancelAnimationFrame = (identifier: number): void => {
+ parkedRafCallbacks.delete(identifier);
+ previousCancelRaf(identifier);
+ };
+
+ window.setTimeout = ((handler: TimerHandler, delayMs?: number, ...args: unknown[]): number => {
+ if (typeof handler !== "function") {
+ return previousSetTimeout(handler, delayMs, ...args);
+ }
+ let timerId = 0;
+ timerId = previousSetTimeout(() => {
+ if (isPageClockFrozen) {
+ parkedTimeoutCallbacks.set(timerId, () => handler.apply(window, args));
+ return;
+ }
+ handler.apply(window, args);
+ }, delayMs);
+ return timerId;
+ }) as typeof window.setTimeout;
+
+ window.clearTimeout = ((identifier?: number): void => {
+ if (typeof identifier === "number") parkedTimeoutCallbacks.delete(identifier);
+ previousClearTimeout(identifier);
+ }) as typeof window.clearTimeout;
+
+ window.setInterval = ((handler: TimerHandler, delayMs?: number, ...args: unknown[]): number => {
+ if (typeof handler !== "function") {
+ return previousSetInterval(handler, delayMs, ...args);
+ }
+ return previousSetInterval(() => {
+ if (isPageClockFrozen) return;
+ handler.apply(window, args);
+ }, delayMs);
+ }) as typeof window.setInterval;
+};
+
+export const freezePageClock = (): void => {
+ if (isPageClockFrozen) return;
+ installPageClockInterception();
+ isPageClockFrozen = true;
+};
+
+// Parked callbacks replay through the (still-wrapped) scheduler rather than
+// running synchronously or natively: synchronous invocation would run rAF
+// callbacks outside a frame and re-enter React mid-travel, and the native
+// scheduler would let them fire even if the clock re-freezes before the next
+// frame (a quick scrub back after returning to now) — routing through the
+// wrappers re-parks them instead.
+export const releasePageClock = (): void => {
+ if (!isPageClockFrozen) return;
+ isPageClockFrozen = false;
+
+ const rafCallbacksToReplay = Array.from(parkedRafCallbacks.values());
+ parkedRafCallbacks.clear();
+ for (const callback of rafCallbacksToReplay) {
+ window.requestAnimationFrame(callback);
+ }
+
+ const timeoutCallbacksToReplay = Array.from(parkedTimeoutCallbacks.values());
+ parkedTimeoutCallbacks.clear();
+ for (const callback of timeoutCallbacksToReplay) {
+ window.setTimeout(callback, 0);
+ }
+};
diff --git a/packages/react-grab/src/core/time-machine-recorder.ts b/packages/react-grab/src/core/time-machine-recorder.ts
new file mode 100644
index 000000000..ec3a7ba2d
--- /dev/null
+++ b/packages/react-grab/src/core/time-machine-recorder.ts
@@ -0,0 +1,756 @@
+// There is no public React API to read or rewrite past state. The recorder
+// observes every state change through bippy's commit instrumentation
+// (onCommitFiberRoot) and diffs each rendered fiber's hook list against its
+// alternate, storing both the previous and next value of every changed
+// stateful hook so the timeline can be replayed in either direction like an
+// undo/redo log. Restoring does NOT use React DevTools' overrideHookState:
+// when React Refresh (Vite/Next dev) creates the DevTools hook before bippy
+// loads, the injected renderer object is never retained anywhere reachable,
+// so the bridge is unavailable exactly where react-grab runs most. Instead:
+//
+// - useState-family hooks replay through their own queue.dispatch — the
+// stable setState dispatcher React stores on the hook queue — which is
+// exact because basicStateReducer invokes a function action with the
+// current state (dispatching `() => value` always lands on value).
+// - useReducer hooks cannot be forced through dispatch (actions feed the
+// app's reducer), so travel writes the hook's memoizedState/baseState
+// directly on both the fiber and its alternate — making the resulting
+// commit diff-invisible — and then forces the component to re-render by
+// "wiggling" the nearest useState queue (a sentinel dispatch immediately
+// superseded by the current value, netting zero).
+// - useSyncExternalStore is deliberately NOT tracked: its state lives in an
+// external store with 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.
+import {
+ ForwardRefTag,
+ FunctionComponentTag,
+ SimpleMemoComponentTag,
+ getDisplayName,
+ getLatestFiber,
+ getTimings,
+ instrument,
+ isCompositeFiber,
+ secure,
+ traverseRenderedFibers,
+ type Fiber,
+ type FiberRoot,
+ type MemoizedState,
+ type RenderPhase,
+} from "bippy";
+import {
+ LONG_ANIMATION_FRAME_ENTRY_TYPE,
+ TIME_MACHINE_COALESCE_WINDOW_MS,
+ TIME_MACHINE_INPUT_ATTRIBUTION_WINDOW_MS,
+ TIME_MACHINE_LOAF_ATTRIBUTION_SLACK_MS,
+ TIME_MACHINE_MAX_ENTRIES,
+ TIME_MACHINE_SETTLE_COALESCE_WINDOW_MS,
+ TIME_MACHINE_SLOW_RENDER_THRESHOLD_MS,
+ TIME_MACHINE_TRAVEL_EXPECTATION_TTL_MS,
+} from "../constants.js";
+import type { TimeMachineTimelineEntry } from "../types.js";
+import { logRecoverableError } from "../utils/log-recoverable-error.js";
+import { releaseAnimationClock, syncAnimationClock } from "./time-machine-animation-clock.js";
+import {
+ freezePageClock,
+ installPageClockInterception,
+ releasePageClock,
+} from "./time-machine-page-clock.js";
+import {
+ applyInteractionSnapshot,
+ captureInteractionSnapshot,
+ releaseInteractionPins,
+ type TimeMachineInteractionSnapshot,
+} from "./time-machine-interaction-snapshot.js";
+
+interface RestorableHookQueue {
+ dispatch: (action: unknown) => void;
+ lastRenderedReducer?: unknown;
+ lastRenderedState?: unknown;
+}
+
+type RestorableHookKind = "state" | "reducer";
+
+interface TimeMachineHookChange {
+ kind: RestorableHookKind;
+ queueRef: WeakRef;
+ fiberRef: WeakRef;
+ previousValue: unknown;
+ nextValue: unknown;
+}
+
+interface TimeMachineEntry {
+ id: number;
+ componentName: string;
+ changes: TimeMachineHookChange[];
+ timestamp: number;
+ interactionSnapshot: TimeMachineInteractionSnapshot | null;
+ renderCount: number;
+ renderDurationMs: number;
+ loafDurationMs: number;
+ loafBlockingMs: number;
+}
+
+// Only useState-family hooks are exactly restorable via a function action;
+// useReducer hooks feed the app's own reducer and cannot be forced to an
+// arbitrary state. Dev builds of React expose the distinction through this
+// internal function name.
+const BASIC_STATE_REDUCER_NAME = "basicStateReducer";
+
+interface StateReducer {
+ (state: unknown, action: unknown): unknown;
+}
+
+const isStateReducerFunction = (value: unknown): value is StateReducer =>
+ typeof value === "function";
+
+const PROBE_CURRENT_STATE = Object.freeze({});
+const PROBE_NEXT_STATE = Object.freeze({});
+const reducerRestorabilityCache = new WeakMap();
+
+// Production React minifies basicStateReducer's name, so the reducer is
+// probed behaviorally instead: only a useState-style reducer maps a function
+// action to that function applied to the current state. Reducers must be
+// pure, and the probe runs once per distinct reducer function.
+const isRestorableStateReducer = (reducer: StateReducer): boolean => {
+ if (reducer.name === BASIC_STATE_REDUCER_NAME) return true;
+ const cachedResult = reducerRestorabilityCache.get(reducer);
+ if (cachedResult !== undefined) return cachedResult;
+ let isRestorable = false;
+ try {
+ isRestorable = reducer(PROBE_CURRENT_STATE, () => PROBE_NEXT_STATE) === PROBE_NEXT_STATE;
+ } catch {
+ isRestorable = false;
+ }
+ reducerRestorabilityCache.set(reducer, isRestorable);
+ return isRestorable;
+};
+
+let isRecording = false;
+let isInstrumented = false;
+let isPanelOpen = false;
+let nextEntryId = 0;
+let history: TimeMachineEntry[] = [];
+let travelCursor = 0;
+let lastUserInputAtMs = 0;
+const historyListeners = new Set<() => void>();
+
+interface TravelExpectation {
+ value: unknown;
+ dispatchedAt: number;
+}
+
+// Travel dispatches cause real commits that would otherwise be re-recorded
+// as new history (and truncate the redo tail). Each travelled queue maps to
+// the ordered list of values it is expected to land on — a list, not a single
+// value, because rapid scrubbing can dispatch several steps before React
+// commits the first one, and those steps may flush as one batched commit or
+// as several. The commit diff consumes expectations up to the matching value;
+// a mismatching diff clears them all and is recorded as a real change. When
+// values repeat (boolean toggles) a batched flush is indistinguishable from a
+// sequential one by value alone and can leave residue, so expectations also
+// expire after a TTL — travel commits flush within milliseconds of their
+// dispatch, so anything older belongs to a flush that already happened.
+let pendingTravelValues = new WeakMap();
+
+const dropExpiredExpectations = (
+ queue: RestorableHookQueue,
+ expectations: TravelExpectation[],
+): TravelExpectation[] | null => {
+ const expiryThreshold = Date.now() - TIME_MACHINE_TRAVEL_EXPECTATION_TTL_MS;
+ while (expectations.length > 0 && expectations[0].dispatchedAt < expiryThreshold) {
+ expectations.shift();
+ }
+ if (expectations.length === 0) {
+ pendingTravelValues.delete(queue);
+ return null;
+ }
+ return expectations;
+};
+
+const notifyHistoryChange = (): void => {
+ for (const listener of historyListeners) {
+ listener();
+ }
+};
+
+const isHookStatefulFiber = (fiber: Fiber): boolean =>
+ fiber.tag === FunctionComponentTag ||
+ fiber.tag === ForwardRefTag ||
+ fiber.tag === SimpleMemoComponentTag;
+
+interface RestorableHookTarget {
+ queue: RestorableHookQueue;
+ kind: RestorableHookKind;
+}
+
+// Requiring lastRenderedReducer scopes tracking to useState/useReducer:
+// useSyncExternalStore's queue carries a getSnapshot instead (its state lives
+// in an external store that cannot be written back), and useActionState's
+// queue carries the action — neither is recordable-and-restorable.
+const getRestorableHookTarget = (hookNode: MemoizedState): RestorableHookTarget | null => {
+ const queue: unknown = hookNode.queue;
+ if (typeof queue !== "object" || queue === null) return null;
+ if (!("dispatch" in queue) || typeof queue.dispatch !== "function") return null;
+ if (!("lastRenderedReducer" in queue)) return null;
+ const reducer = queue.lastRenderedReducer;
+ if (!isStateReducerFunction(reducer)) return null;
+ return {
+ queue: queue as RestorableHookQueue,
+ kind: isRestorableStateReducer(reducer) ? "state" : "reducer",
+ };
+};
+
+// Hooks live on fiber.memoizedState as a linked list; walking the current and
+// alternate lists in lockstep pairs each hook with its previous render's
+// value, so no separate baseline bookkeeping is needed.
+const collectHookChanges = (fiber: Fiber): TimeMachineHookChange[] | null => {
+ const alternate = fiber.alternate;
+ if (!alternate) return null;
+ let changes: TimeMachineHookChange[] | null = null;
+ let currentHook: MemoizedState | null = fiber.memoizedState;
+ let previousHook: MemoizedState | null = alternate.memoizedState;
+ while (currentHook && previousHook && typeof currentHook === "object") {
+ const target = getRestorableHookTarget(currentHook);
+ if (target) {
+ if (!Object.is(currentHook.memoizedState, previousHook.memoizedState)) {
+ if (!consumeTravelExpectation(target.queue, currentHook.memoizedState)) {
+ changes ??= [];
+ changes.push({
+ kind: target.kind,
+ queueRef: new WeakRef(target.queue),
+ fiberRef: new WeakRef(fiber),
+ previousValue: previousHook.memoizedState,
+ nextValue: currentHook.memoizedState,
+ });
+ }
+ } else {
+ settleTravelExpectations(target.queue, currentHook.memoizedState);
+ }
+ }
+ currentHook = currentHook.next;
+ previousHook = previousHook.next;
+ }
+ return changes;
+};
+
+// A scrub that returns to the origin value nets out to a render with no
+// state diff, which would strand its expectations; once the committed value
+// matches the last expected value the whole batch has flushed.
+const settleTravelExpectations = (queue: RestorableHookQueue, committedValue: unknown): void => {
+ const expectations = pendingTravelValues.get(queue);
+ if (!expectations) return;
+ const liveExpectations = dropExpiredExpectations(queue, expectations);
+ if (!liveExpectations) return;
+ if (Object.is(liveExpectations[liveExpectations.length - 1].value, committedValue)) {
+ pendingTravelValues.delete(queue);
+ }
+};
+
+// Returns true when the committed value belongs to an in-flight travel batch.
+// React batches all dispatches from one travelTo call into a single commit
+// landing on the final value, so the LAST matching occurrence is the right
+// anchor when values repeat (boolean toggles): everything up to it was
+// superseded within the same flush. A mismatching diff is a real change and
+// clears the queue's expectations entirely.
+const consumeTravelExpectation = (queue: RestorableHookQueue, committedValue: unknown): boolean => {
+ const expectations = pendingTravelValues.get(queue);
+ if (!expectations) return false;
+ const liveExpectations = dropExpiredExpectations(queue, expectations);
+ if (!liveExpectations) return false;
+ const matchIndex = liveExpectations.findLastIndex((expectation) =>
+ Object.is(expectation.value, committedValue),
+ );
+ if (matchIndex === -1) {
+ pendingTravelValues.delete(queue);
+ return false;
+ }
+ liveExpectations.splice(0, matchIndex + 1);
+ if (liveExpectations.length === 0) {
+ pendingTravelValues.delete(queue);
+ }
+ return true;
+};
+
+// A settling commit only touches hook queues the previous entry already
+// changed: an animation/transition finishing what that entry started, never
+// a new interaction (which would involve at least one fresh queue).
+const areAllQueuesInEntry = (
+ entry: TimeMachineEntry,
+ changes: TimeMachineHookChange[],
+): boolean => {
+ for (const newChange of changes) {
+ const newQueue = newChange.queueRef.deref();
+ if (!newQueue) continue;
+ const isKnownQueue = entry.changes.some((change) => change.queueRef.deref() === newQueue);
+ if (!isKnownQueue) return false;
+ }
+ return true;
+};
+
+// Animation-driven state (text scrambles, count-ups, springs) commits on
+// every tick, often spread across many small component instances (one per
+// text grapheme is common). Recording each tick would fill the timeline with
+// transient mid-animation frames, and scrubbing onto one restores garbled
+// intermediate state (e.g. a text morph showing both the old and new text).
+// Two coalescing tiers fold such commits into the previous entry — a short
+// unconditional window for burst ticks, and a longer queue-keyed window for
+// the delayed cleanup commit that ends a transition. Per queue, previousValue
+// stays the settled state before the burst and nextValue tracks the latest,
+// so a whole burst scrubs as one step between two "settled moments".
+const tryCoalesceIntoLastEntry = (changes: TimeMachineHookChange[], now: number): boolean => {
+ if (travelCursor !== history.length) return false;
+ // A commit right after a pointer/keyboard event is user-driven and must
+ // stay its own scrub step — two quick clicks on the same toggle are two
+ // moments, not one burst.
+ if (now - lastUserInputAtMs <= TIME_MACHINE_INPUT_ATTRIBUTION_WINDOW_MS) return false;
+ const lastEntry = history[history.length - 1];
+ if (!lastEntry) return false;
+ const elapsedMs = now - lastEntry.timestamp;
+ if (elapsedMs > TIME_MACHINE_SETTLE_COALESCE_WINDOW_MS) return false;
+ if (elapsedMs > TIME_MACHINE_COALESCE_WINDOW_MS && !areAllQueuesInEntry(lastEntry, changes)) {
+ return false;
+ }
+
+ for (const newChange of changes) {
+ const newQueue = newChange.queueRef.deref();
+ const existingChange = newQueue
+ ? lastEntry.changes.find((change) => change.queueRef.deref() === newQueue)
+ : undefined;
+ if (existingChange) {
+ existingChange.nextValue = newChange.nextValue;
+ } else {
+ lastEntry.changes.push(newChange);
+ }
+ }
+ lastEntry.timestamp = now;
+ lastEntry.renderCount += commitRenderCount;
+ lastEntry.renderDurationMs += commitRenderDurationMs;
+ return true;
+};
+
+const recordEntry = (componentName: string, changes: TimeMachineHookChange[]): void => {
+ if (travelCursor < history.length) {
+ // While the panel is open, background app activity (timers, network)
+ // must not destroy the part of the timeline the user is scrubbing
+ // through, so rewound recording drops instead of forking. The dropped
+ // change is not lost state-consistency-wise: travel dispatches absolute
+ // recorded values (never deltas), so continuing to scrub snaps every
+ // recorded hook exactly back onto the timeline regardless of any drift
+ // that happened in between.
+ if (isPanelOpen) return;
+ // Recording while rewound forks the timeline: the undone tail is dropped
+ // so the new change becomes the latest point in history (classic
+ // undo/redo semantics).
+ history.length = travelCursor;
+ }
+ const now = Date.now();
+ if (tryCoalesceIntoLastEntry(changes, now)) {
+ notifyHistoryChange();
+ return;
+ }
+ history.push({
+ id: nextEntryId++,
+ componentName,
+ changes,
+ timestamp: now,
+ interactionSnapshot: captureInteractionSnapshot(),
+ renderCount: commitRenderCount,
+ renderDurationMs: commitRenderDurationMs,
+ loafDurationMs: 0,
+ loafBlockingMs: 0,
+ });
+ if (history.length > TIME_MACHINE_MAX_ENTRIES) {
+ history.splice(0, history.length - TIME_MACHINE_MAX_ENTRIES);
+ }
+ travelCursor = history.length;
+ notifyHistoryChange();
+};
+
+// All hook changes within one commit accumulate into a single timeline entry:
+// React batches state updates across components, and splitting a batch into
+// per-fiber steps would let the scrubber land on combined states that never
+// existed in the real app.
+let commitChanges: TimeMachineHookChange[] | null = null;
+let commitComponentName: string | null = null;
+let commitRenderCount = 0;
+let commitRenderDurationMs = 0;
+
+const handleRenderedFiber = (fiber: Fiber, phase: RenderPhase): void => {
+ if (phase === "unmount") return;
+ if (isCompositeFiber(fiber)) {
+ commitRenderCount += 1;
+ // Self time (actualDuration minus children) so a commit's cost sums each
+ // component once; only populated in React profiling builds (default-on in
+ // dev), plain production builds report 0.
+ commitRenderDurationMs += Math.max(0, getTimings(fiber).selfTime);
+ }
+ if (phase !== "update") return;
+ if (!isHookStatefulFiber(fiber)) return;
+ const changes = collectHookChanges(fiber);
+ if (!changes) return;
+ if (commitChanges) {
+ commitChanges.push(...changes);
+ } else {
+ commitChanges = changes;
+ commitComponentName = getDisplayName(fiber.type) ?? "Anonymous";
+ }
+};
+
+const handleCommitFiberRoot = (_rendererId: number, root: FiberRoot): void => {
+ if (!isRecording) return;
+ try {
+ commitChanges = null;
+ commitComponentName = null;
+ commitRenderCount = 0;
+ commitRenderDurationMs = 0;
+ traverseRenderedFibers(root, handleRenderedFiber);
+ if (commitChanges) {
+ recordEntry(commitComponentName ?? "Anonymous", commitChanges);
+ }
+ } catch (error) {
+ logRecoverableError("Time machine failed to record commit", error);
+ } finally {
+ commitChanges = null;
+ commitComponentName = null;
+ }
+};
+
+const markUserInput = (): void => {
+ lastUserInputAtMs = Date.now();
+};
+
+const USER_INPUT_EVENTS = ["pointerdown", "pointerup", "keydown"] as const;
+
+// The `long-animation-frame` entry shape is not yet in the DOM lib types.
+interface LongAnimationFrameTiming extends PerformanceEntry {
+ blockingDuration?: number;
+}
+
+let loafObserver: PerformanceObserver | null = null;
+
+// A commit that triggers a long animation frame lands inside that frame's
+// window, so the LoAF is charged to the newest entry whose timestamp falls
+// within it. LoAF entries deliver asynchronously — after the frame ends —
+// which is why attribution happens by time window instead of at commit time.
+const attributeLongAnimationFrame = (loafEntry: LongAnimationFrameTiming): boolean => {
+ const frameStartEpochMs =
+ performance.timeOrigin + loafEntry.startTime - TIME_MACHINE_LOAF_ATTRIBUTION_SLACK_MS;
+ const frameEndEpochMs =
+ performance.timeOrigin +
+ loafEntry.startTime +
+ loafEntry.duration +
+ TIME_MACHINE_LOAF_ATTRIBUTION_SLACK_MS;
+ for (let entryIndex = history.length - 1; entryIndex >= 0; entryIndex--) {
+ const entry = history[entryIndex];
+ if (entry.timestamp > frameEndEpochMs) continue;
+ if (entry.timestamp < frameStartEpochMs) break;
+ entry.loafDurationMs += loafEntry.duration;
+ entry.loafBlockingMs += loafEntry.blockingDuration ?? 0;
+ return true;
+ }
+ return false;
+};
+
+const handleLongAnimationFrames = (entryList: PerformanceObserverEntryList): void => {
+ if (!isRecording) return;
+ let didAttribute = false;
+ for (const entry of entryList.getEntries()) {
+ if (attributeLongAnimationFrame(entry)) didAttribute = true;
+ }
+ if (didAttribute) notifyHistoryChange();
+};
+
+const startLongAnimationFrameObserver = (): void => {
+ if (loafObserver) return;
+ if (typeof PerformanceObserver === "undefined") return;
+ if (!PerformanceObserver.supportedEntryTypes?.includes(LONG_ANIMATION_FRAME_ENTRY_TYPE)) return;
+ loafObserver = new PerformanceObserver(handleLongAnimationFrames);
+ try {
+ loafObserver.observe({ type: LONG_ANIMATION_FRAME_ENTRY_TYPE, buffered: false });
+ } catch {
+ loafObserver = null;
+ }
+};
+
+export const startTimeMachineRecorder = (): void => {
+ isRecording = true;
+ startLongAnimationFrameObserver();
+ if (isInstrumented) return;
+ isInstrumented = true;
+ // Passive capture listeners so coalescing can tell user-driven commits
+ // (which must stay distinct scrub steps) apart from ambient animation and
+ // timer commits. Never removed: instrument() below is also permanent, and
+ // a timestamp write per interaction is free.
+ for (const eventType of USER_INPUT_EVENTS) {
+ window.addEventListener(eventType, markUserInput, { capture: true, passive: true });
+ }
+ // The scheduler wrappers only see timers created after they install, so
+ // installation happens now — before the app's effects register their
+ // interval tickers — not lazily at the first rewind.
+ installPageClockInterception();
+ instrument(
+ secure(
+ { onCommitFiberRoot: handleCommitFiberRoot },
+ {
+ // react-grab intentionally runs against production React builds
+ // (e.g. the react-grab website itself); without this flag, secure()
+ // silently uninstalls the commit handler there and no history is
+ // ever recorded.
+ dangerouslyRunInProduction: true,
+ onError: (error) => logRecoverableError("Time machine instrumentation failed", error),
+ },
+ ),
+ );
+};
+
+// bippy's instrument() cannot be uninstalled, so stopping flips the recording
+// flag off and drops the timeline; the commit handler stays as a no-op.
+// Travel expectations are dropped too: with recording off, the commits that
+// would consume them are never diffed, and a leftover expectation could
+// swallow a real change after a restart.
+export const stopTimeMachineRecorder = (): void => {
+ isRecording = false;
+ history = [];
+ travelCursor = 0;
+ pendingTravelValues = new WeakMap();
+ loafObserver?.disconnect();
+ loafObserver = null;
+ notifyHistoryChange();
+};
+
+// Closing the panel keeps the travelled state but lets time flow again — a
+// page whose animations stay frozen (or hover styles stay pinned) after the
+// scrubber is gone reads as broken, not as time-travelled. Reopening while
+// the cursor still sits in the past re-enters the frozen-time regime.
+export const setTimeMachinePanelOpen = (isOpen: boolean): void => {
+ isPanelOpen = isOpen;
+ if (isOpen) {
+ syncTimeFreezeToCursor();
+ } else {
+ releasePageClock();
+ releaseAnimationClock();
+ releaseInteractionPins();
+ }
+};
+
+export const subscribeToTimeMachineHistory = (listener: () => void): (() => void) => {
+ historyListeners.add(listener);
+ return () => {
+ historyListeners.delete(listener);
+ };
+};
+
+export const getTimeMachineTimeline = (): TimeMachineTimelineEntry[] =>
+ history.map((entry) => ({
+ id: entry.id,
+ componentName: entry.componentName,
+ changeCount: entry.changes.length,
+ timestamp: entry.timestamp,
+ renderCount: entry.renderCount,
+ renderDurationMs: entry.renderDurationMs,
+ loafDurationMs: entry.loafDurationMs,
+ loafBlockingMs: entry.loafBlockingMs,
+ hasPerfIssue:
+ entry.loafDurationMs > 0 || entry.renderDurationMs > TIME_MACHINE_SLOW_RENDER_THRESHOLD_MS,
+ }));
+
+export const getTimeMachineCursor = (): number => travelCursor;
+
+export const hasTimeMachineHistory = (): boolean => history.length > 0;
+
+// The value a queue is about to land on: the tail of its in-flight travel
+// expectations if any, otherwise its last rendered state.
+const readQueueLogicalValue = (queue: RestorableHookQueue): unknown => {
+ const expectations = pendingTravelValues.get(queue);
+ const tailExpectation = expectations?.[expectations.length - 1];
+ if (tailExpectation) return tailExpectation.value;
+ return queue.lastRenderedState;
+};
+
+// Returns true when a render was actually scheduled (a skipped no-op
+// dispatch schedules nothing).
+const applyStateHookValue = (change: TimeMachineHookChange, value: unknown): boolean => {
+ const queue = change.queueRef.deref();
+ if (!queue) return false;
+ // Skipping the no-op dispatch matters beyond perf: React's eager bailout
+ // would never commit it, leaving a stale expected value that could swallow
+ // a future legitimate change to the same value. With dispatches already in
+ // flight, queue.lastRenderedState is stale, so the value the queue is about
+ // to land on is the tail of the expectation list.
+ if (Object.is(readQueueLogicalValue(queue), value)) return false;
+ const expectations = pendingTravelValues.get(queue) ?? [];
+ expectations.push({ value, dispatchedAt: Date.now() });
+ pendingTravelValues.set(queue, expectations);
+ try {
+ queue.dispatch(() => value);
+ return true;
+ } catch (error) {
+ expectations.pop();
+ if (expectations.length === 0) {
+ pendingTravelValues.delete(queue);
+ }
+ logRecoverableError("Time machine failed to restore hook state", error);
+ return false;
+ }
+};
+
+const findHookByQueue = (fiber: Fiber, queue: RestorableHookQueue): MemoizedState | null => {
+ let hookNode: MemoizedState | null = fiber.memoizedState;
+ while (hookNode && typeof hookNode === "object") {
+ if (hookNode.queue === queue) return hookNode;
+ hookNode = hookNode.next;
+ }
+ return null;
+};
+
+// React's render-phase bailout discards a render whose props identity and
+// hook states all end where they started — which describes both a fiber
+// whose reducer hook was written directly (no scheduling state changed) and
+// a net-zero wiggle. Cloning memoizedProps flips beginWork's oldProps !==
+// newProps check so the render commits; the same trick React DevTools'
+// overrideHookState uses. Shallow clone, so memo comparisons stay equal.
+const invalidatePropsIdentity = (fiber: Fiber): void => {
+ const memoizedProps: unknown = fiber.memoizedProps;
+ if (typeof memoizedProps !== "object" || memoizedProps === null) return;
+ fiber.memoizedProps = { ...memoizedProps };
+};
+
+// useReducer state cannot be forced through dispatch (actions feed the app's
+// reducer), so travel writes the hook's memoizedState/baseState directly on
+// BOTH the fiber and its alternate. Writing both sides is what keeps the
+// next render truthful (React clones hooks from whichever fiber is current)
+// and makes the forced commit diff-invisible to the recorder — new and old
+// memoizedState compare equal, so no bogus entry and no expectation
+// bookkeeping is needed.
+const writeReducerHookValue = (change: TimeMachineHookChange, value: unknown): boolean => {
+ const queue = change.queueRef.deref();
+ const recordedFiber = change.fiberRef.deref();
+ if (!queue || !recordedFiber) return false;
+ const latestFiber = getLatestFiber(recordedFiber);
+ let didWrite = false;
+ for (const fiber of [latestFiber, latestFiber.alternate]) {
+ const hook = fiber ? findHookByQueue(fiber, queue) : null;
+ if (!hook) continue;
+ if (!Object.is(hook.memoizedState, value)) didWrite = true;
+ hook.memoizedState = value;
+ hook.baseState = value;
+ }
+ if (didWrite) {
+ // Keeps React's eager-dispatch bailout comparing future real actions
+ // against the travelled state instead of the stale one.
+ queue.lastRenderedState = value;
+ invalidatePropsIdentity(latestFiber);
+ }
+ return didWrite;
+};
+
+const WIGGLE_SENTINEL = Object.freeze({});
+
+const wiggleStateQueueOnFiber = (fiber: Fiber): boolean => {
+ let hookNode: MemoizedState | null = fiber.memoizedState;
+ while (hookNode && typeof hookNode === "object") {
+ const target = getRestorableHookTarget(hookNode);
+ if (target && target.kind === "state") {
+ const restoreValue = readQueueLogicalValue(target.queue);
+ // The wiggle nets to zero, so this fiber's own bailout must also be
+ // defeated or React discards the forced render entirely.
+ invalidatePropsIdentity(fiber);
+ try {
+ target.queue.dispatch(() => WIGGLE_SENTINEL);
+ target.queue.dispatch(() => restoreValue);
+ return true;
+ } catch (error) {
+ logRecoverableError("Time machine failed to force a re-render", error);
+ return false;
+ }
+ }
+ hookNode = hookNode.next;
+ }
+ return false;
+};
+
+// A directly-written reducer hook changes no scheduling state, so nothing
+// re-renders the component and the DOM would keep showing the old output.
+// With no reachable renderer.scheduleUpdate (see module header), the render
+// is forced by "wiggling" a useState queue: one dispatch to a unique sentinel
+// (guaranteed to schedule) and one straight back to its logical value. The
+// batch nets to zero — the commit shows no diff for the wiggled hook — but
+// the render it forces re-reads the reducer hook's written state. A sibling
+// hook on the same fiber is preferred; a reducer-only component borrows the
+// nearest function-component ancestor with state instead, whose re-render
+// reaches the reducer fiber because its props identity was invalidated.
+const forceFiberRender = (fiber: Fiber): boolean => {
+ let currentFiber: Fiber | null = getLatestFiber(fiber);
+ while (currentFiber) {
+ if (isHookStatefulFiber(currentFiber) && wiggleStateQueueOnFiber(currentFiber)) {
+ return true;
+ }
+ currentFiber = currentFiber.return;
+ }
+ return false;
+};
+
+const applyEntryValues = (entry: TimeMachineEntry, shouldApplyNext: boolean): void => {
+ // Reducer writes go first so the render forced by this step's state
+ // dispatches (or by the wiggle) commits them in the same pass.
+ let fibersAwaitingRender: Set | null = null;
+ for (const change of entry.changes) {
+ if (change.kind !== "reducer") continue;
+ const value = shouldApplyNext ? change.nextValue : change.previousValue;
+ const recordedFiber = change.fiberRef.deref();
+ if (writeReducerHookValue(change, value) && recordedFiber) {
+ fibersAwaitingRender ??= new Set();
+ fibersAwaitingRender.add(getLatestFiber(recordedFiber));
+ }
+ }
+
+ for (const change of entry.changes) {
+ if (change.kind !== "state") continue;
+ const value = shouldApplyNext ? change.nextValue : change.previousValue;
+ const didScheduleRender = applyStateHookValue(change, value);
+ if (didScheduleRender && fibersAwaitingRender) {
+ const dispatchedFiber = change.fiberRef.deref();
+ if (dispatchedFiber) fibersAwaitingRender.delete(getLatestFiber(dispatchedFiber));
+ }
+ }
+
+ if (fibersAwaitingRender) {
+ for (const fiber of fibersAwaitingRender) {
+ forceFiberRender(fiber);
+ }
+ }
+};
+
+// Rewinding state without rewinding motion or interaction visuals looks
+// broken, so while the cursor sits in the past the page's scheduling and
+// animation clocks are frozen at the rewound moment and the hover/focus/
+// active styling captured with the current entry is pinned back on; at the
+// newest entry, time flows again and the live pseudo-classes take over.
+const syncTimeFreezeToCursor = (): void => {
+ if (travelCursor < history.length) {
+ const rewoundMomentEntry = history[Math.max(0, travelCursor - 1)];
+ freezePageClock();
+ syncAnimationClock(rewoundMomentEntry.timestamp);
+ applyInteractionSnapshot(travelCursor === 0 ? null : rewoundMomentEntry.interactionSnapshot);
+ } else {
+ releasePageClock();
+ releaseAnimationClock();
+ releaseInteractionPins();
+ }
+};
+
+export const travelTimeMachineTo = (targetCursor: number): void => {
+ const clampedCursor = Math.max(0, Math.min(history.length, Math.round(targetCursor)));
+ if (clampedCursor === travelCursor) return;
+ while (travelCursor > clampedCursor) {
+ travelCursor -= 1;
+ applyEntryValues(history[travelCursor], false);
+ }
+ while (travelCursor < clampedCursor) {
+ applyEntryValues(history[travelCursor], true);
+ travelCursor += 1;
+ }
+ syncTimeFreezeToCursor();
+ notifyHistoryChange();
+};
diff --git a/packages/react-grab/src/core/time-machine.ts b/packages/react-grab/src/core/time-machine.ts
new file mode 100644
index 000000000..17337fccb
--- /dev/null
+++ b/packages/react-grab/src/core/time-machine.ts
@@ -0,0 +1,158 @@
+import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js";
+import type { Position, TimeMachinePanelState, TimeMachineTimelineEntry } from "../types.js";
+import { getTagName } from "../utils/get-tag-name.js";
+import { getNearestComponentName } from "./context.js";
+import {
+ getTimeMachineCursor,
+ getTimeMachineTimeline,
+ setTimeMachinePanelOpen,
+ subscribeToTimeMachineHistory,
+ travelTimeMachineTo,
+} from "./time-machine-recorder.js";
+
+export interface TimeMachineTriggerOptions {
+ element?: Element;
+ componentName?: string;
+ tagName?: string;
+}
+
+interface TimeMachineDependencies {
+ store: {
+ wasActivatedByToggle: boolean;
+ };
+ actions: {
+ setPointer: (position: Position) => void;
+ setFrozenElement: (element: Element) => void;
+ freeze: () => void;
+ unfreeze: () => void;
+ };
+ isActivated: Accessor;
+ activateRenderer: () => void;
+ deactivateRenderer: () => void;
+ onOpen?: () => void;
+ onClose?: () => void;
+}
+
+export interface TimeMachineController {
+ state: Accessor;
+ entries: Accessor;
+ cursor: Accessor;
+ trigger: (position: Position, options?: TimeMachineTriggerOptions) => boolean;
+ travelTo: (cursor: number) => void;
+ dismiss: () => void;
+ closePreservingRenderer: () => void;
+ reset: () => void;
+ isOpen: Accessor;
+}
+
+export const createTimeMachineController = (
+ dependencies: TimeMachineDependencies,
+): TimeMachineController => {
+ const [state, setState] = createSignal(null);
+ const [historyVersion, setHistoryVersion] = createSignal(0);
+ // Element-scoped opens (context menu / shortcut) freeze the page like the
+ // style panel does; toolbar opens are a lightweight utility that leaves the
+ // app running live, so dismissal must not unfreeze or deactivate anything.
+ let didFreezeOnOpen = false;
+
+ const unsubscribeFromHistory = subscribeToTimeMachineHistory(() => {
+ setHistoryVersion((version) => version + 1);
+ });
+ onCleanup(unsubscribeFromHistory);
+
+ const entries = createMemo(() => {
+ historyVersion();
+ return getTimeMachineTimeline();
+ });
+
+ const cursor = createMemo(() => {
+ historyVersion();
+ return getTimeMachineCursor();
+ });
+
+ const resolveComponentNameIntoState = (element: Element) => {
+ void getNearestComponentName(element).then((nearestComponentName) => {
+ if (!nearestComponentName) return;
+ setState((current) => {
+ if (!current || current.element !== element || current.componentName) return current;
+ return { ...current, componentName: nearestComponentName };
+ });
+ });
+ };
+
+ const trigger = (position: Position, options: TimeMachineTriggerOptions = {}): boolean => {
+ if (state() !== null) return false;
+
+ const element = options.element;
+ setState({
+ element,
+ position,
+ componentName: options.componentName,
+ tagName: options.tagName ?? (element ? getTagName(element) : undefined),
+ });
+
+ setTimeMachinePanelOpen(true);
+ didFreezeOnOpen = Boolean(element);
+
+ if (element) {
+ resolveComponentNameIntoState(element);
+ // Order matters: actions.freeze() is a no-op unless the state machine
+ // is already "active", so the renderer must activate first.
+ if (!dependencies.isActivated()) {
+ dependencies.activateRenderer();
+ }
+ dependencies.actions.setPointer(position);
+ dependencies.actions.setFrozenElement(element);
+ dependencies.actions.freeze();
+ }
+
+ dependencies.onOpen?.();
+ return true;
+ };
+
+ // Dismissal keeps whatever point in time the user travelled to; the panel
+ // is a viewport into history, not a transaction to roll back.
+ const dismiss = () => {
+ if (state() === null) return;
+ setState(null);
+ setTimeMachinePanelOpen(false);
+ dependencies.onClose?.();
+ if (!didFreezeOnOpen) return;
+ if (dependencies.store.wasActivatedByToggle) {
+ dependencies.deactivateRenderer();
+ } else {
+ dependencies.actions.unfreeze();
+ }
+ };
+
+ const closePreservingRenderer = () => {
+ if (state() === null) return;
+ setState(null);
+ setTimeMachinePanelOpen(false);
+ dependencies.onClose?.();
+ if (didFreezeOnOpen) {
+ dependencies.actions.unfreeze();
+ }
+ };
+
+ // For teardown paths that already handle deactivation/unfreezing themselves
+ // (e.g. deactivateRenderer), only the panel state is cleared.
+ const reset = () => {
+ if (state() === null) return;
+ setState(null);
+ setTimeMachinePanelOpen(false);
+ dependencies.onClose?.();
+ };
+
+ return {
+ state,
+ entries,
+ cursor,
+ trigger,
+ travelTo: travelTimeMachineTo,
+ dismiss,
+ closePreservingRenderer,
+ reset,
+ isOpen: () => state() !== null,
+ };
+};
diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts
index 4498a42a1..a402ba8da 100644
--- a/packages/react-grab/src/index.ts
+++ b/packages/react-grab/src/index.ts
@@ -7,6 +7,7 @@ export {
} from "./core/index.js";
export { commentPlugin } from "./core/plugins/comment.js";
export { openPlugin } from "./core/plugins/open.js";
+export { timeMachinePlugin } from "./core/plugins/time-machine.js";
export { generateSnippet } from "./utils/generate-snippet.js";
export type {
Options,
@@ -36,6 +37,8 @@ export type {
PluginHooks,
SelectedElementPayload,
ElementSelectedEventDetail,
+ TimeMachinePanelState,
+ TimeMachineTimelineEntry,
} from "./types.js";
import { init } from "./core/index.js";
diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts
index a7901b206..da2891179 100644
--- a/packages/react-grab/src/types.ts
+++ b/packages/react-grab/src/types.ts
@@ -158,6 +158,26 @@ export interface ActionContext {
export interface ContextMenuActionContext extends ActionContext {
copy?: () => void;
enterEditMode?: () => void;
+ enterTimeMachine?: () => void;
+}
+
+export interface TimeMachineTimelineEntry {
+ id: number;
+ componentName: string;
+ changeCount: number;
+ timestamp: number;
+ renderCount: number;
+ renderDurationMs: number;
+ loafDurationMs: number;
+ loafBlockingMs: number;
+ hasPerfIssue: boolean;
+}
+
+export interface TimeMachinePanelState {
+ element?: Element;
+ position: Position;
+ componentName?: string;
+ tagName?: string;
}
interface EditablePropertyBase {
@@ -595,6 +615,12 @@ export interface ReactGrabRendererProps {
onEditPanelSubmit?: (pendingEdits: PendingEdits) => void;
onEditPanelPendingEditsChange?: (pendingEdits: PendingEdits) => void;
onEditPanelInteractingChange?: (interacting: boolean) => void;
+ timeMachineState?: TimeMachinePanelState | null;
+ timeMachinePosition?: DropdownAnchor | null;
+ timeMachineEntries?: TimeMachineTimelineEntry[];
+ timeMachineCursor?: number;
+ onTimeMachineTravel?: (cursor: number) => void;
+ onTimeMachineDismiss?: () => void;
}
export interface GrabbedBox {
diff --git a/packages/react-grab/src/utils/format-entry-perf.ts b/packages/react-grab/src/utils/format-entry-perf.ts
new file mode 100644
index 000000000..5ab621ff1
--- /dev/null
+++ b/packages/react-grab/src/utils/format-entry-perf.ts
@@ -0,0 +1,9 @@
+import type { TimeMachineTimelineEntry } from "../types.js";
+
+export const formatEntryPerf = (entry: TimeMachineTimelineEntry): string | null => {
+ if (!entry.hasPerfIssue) return null;
+ // The long animation frame subsumes the render work inside it, so the
+ // frame duration is the more truthful single number when both exist.
+ if (entry.loafDurationMs > 0) return `${Math.round(entry.loafDurationMs)}ms frame`;
+ return `${Math.round(entry.renderDurationMs)}ms render`;
+};
diff --git a/packages/react-grab/src/utils/format-relative-time.ts b/packages/react-grab/src/utils/format-relative-time.ts
new file mode 100644
index 000000000..8012c7be3
--- /dev/null
+++ b/packages/react-grab/src/utils/format-relative-time.ts
@@ -0,0 +1,12 @@
+const SECOND_MS = 1000;
+const MINUTE_MS = 60 * SECOND_MS;
+const HOUR_MS = 60 * MINUTE_MS;
+const JUST_NOW_THRESHOLD_MS = 10 * SECOND_MS;
+
+export const formatRelativeTime = (timestamp: number): string => {
+ const elapsedMs = Math.max(0, Date.now() - timestamp);
+ if (elapsedMs < JUST_NOW_THRESHOLD_MS) return "just now";
+ if (elapsedMs < MINUTE_MS) return `${Math.floor(elapsedMs / SECOND_MS)}s ago`;
+ if (elapsedMs < HOUR_MS) return `${Math.floor(elapsedMs / MINUTE_MS)}m ago`;
+ return `${Math.floor(elapsedMs / HOUR_MS)}h ago`;
+};
diff --git a/packages/react-grab/src/utils/freeze-animations.ts b/packages/react-grab/src/utils/freeze-animations.ts
index a948bc5f4..8ecf7d908 100644
--- a/packages/react-grab/src/utils/freeze-animations.ts
+++ b/packages/react-grab/src/utils/freeze-animations.ts
@@ -125,7 +125,7 @@ const finishAnimations = (animations: Iterable): void => {
// Animations whose target lives in a shadow root are react-grab's own toolbar/
// label animations — the global freeze must leave them running.
// @see https://github.com/aidenybai/react-grab/issues/163
-const isShadowAnimation = (animation: Animation): boolean => {
+export const isShadowAnimation = (animation: Animation): boolean => {
if (!(animation.effect instanceof KeyframeEffect)) return false;
const target = animation.effect.target;
return target instanceof Element && target.getRootNode() instanceof ShadowRoot;
diff --git a/packages/react-grab/src/utils/freeze-pseudo-states.ts b/packages/react-grab/src/utils/freeze-pseudo-states.ts
index 63a25ba29..66d84f885 100644
--- a/packages/react-grab/src/utils/freeze-pseudo-states.ts
+++ b/packages/react-grab/src/utils/freeze-pseudo-states.ts
@@ -25,7 +25,7 @@ const FOCUS_EVENTS_TO_BLOCK = ["focus", "blur", "focusin", "focusout"] as const;
// Before disabling pointer-events we snapshot current :hover and :focus computed
// values (background-color, box-shadow, opacity, etc.) onto inline styles so
// elements keep their visual state (e.g. a hovered button stays highlighted).
-const HOVER_STYLE_PROPERTIES = [
+export const HOVER_STYLE_PROPERTIES = [
"background-color",
"color",
"border-color",
@@ -38,7 +38,7 @@ const HOVER_STYLE_PROPERTIES = [
"visibility",
] as const;
-const FOCUS_STYLE_PROPERTIES = [
+export const FOCUS_STYLE_PROPERTIES = [
"background-color",
"color",
"border-color",
@@ -119,7 +119,7 @@ const collectHoveredElements = (cursorX: number, cursorY: number): HTMLElement[]
return hoveredElements;
};
-const collectFocusedElements = (): HTMLElement[] => {
+export const collectFocusedElements = (): HTMLElement[] => {
const focusedElements: HTMLElement[] = [];
let current: Element | null = document.activeElement;
while (current && current !== document.body) {
diff --git a/packages/react-grab/src/utils/native-timers.ts b/packages/react-grab/src/utils/native-timers.ts
new file mode 100644
index 000000000..f435d5718
--- /dev/null
+++ b/packages/react-grab/src/utils/native-timers.ts
@@ -0,0 +1,59 @@
+// Browser-only signatures: with @types/node present, `window.setTimeout`'s
+// declared type merges Node's Timeout-returning overloads, which poisons
+// every call site's ReturnType inference.
+interface ScheduleTimerFunction {
+ (handler: () => void, delayMs?: number): number;
+}
+
+interface ClearTimerFunction {
+ (timerId: number | undefined): void;
+}
+
+interface UnwrappedTimerFunctions {
+ setTimeout: ScheduleTimerFunction;
+ clearTimeout: ClearTimerFunction;
+ setInterval: ScheduleTimerFunction;
+ clearInterval: ClearTimerFunction;
+}
+
+// Unlike requestAnimationFrame, the timer functions live as own properties of
+// the window instance (not on Window.prototype), so there is no prototype to
+// recover natives from once the time machine's page-clock freeze wraps them.
+// And this module lives in the lazily-loaded renderer chunk, which evaluates
+// AFTER those wrappers install — binding window.setTimeout at module load
+// would capture the wrapper and react-grab's own UI timers (hold-to-repeat
+// scrubbing, flash timers) would park during a rewind. Instead the page-clock
+// module registers the pre-wrapper functions it captured at interception
+// time, and every call resolves through that registry, falling back to the
+// live window functions when no interception has installed.
+let unwrappedTimers: UnwrappedTimerFunctions | null = null;
+
+export const registerUnwrappedTimers = (timers: UnwrappedTimerFunctions): void => {
+ unwrappedTimers ??= timers;
+};
+
+export const nativeSetTimeout: ScheduleTimerFunction = (handler, delayMs) =>
+ unwrappedTimers
+ ? unwrappedTimers.setTimeout(handler, delayMs)
+ : window.setTimeout(handler, delayMs);
+
+export const nativeClearTimeout: ClearTimerFunction = (timerId) => {
+ if (unwrappedTimers) {
+ unwrappedTimers.clearTimeout(timerId);
+ } else {
+ window.clearTimeout(timerId);
+ }
+};
+
+export const nativeSetInterval: ScheduleTimerFunction = (handler, delayMs) =>
+ unwrappedTimers
+ ? unwrappedTimers.setInterval(handler, delayMs)
+ : window.setInterval(handler, delayMs);
+
+export const nativeClearInterval: ClearTimerFunction = (timerId) => {
+ if (unwrappedTimers) {
+ unwrappedTimers.clearInterval(timerId);
+ } else {
+ window.clearInterval(timerId);
+ }
+};