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} + +
+ ); +}; + +// 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} + + +
+ ); +}; + +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} + +
+ ); +}; + +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} + +
+ ); +}; + +const ReducerSection = () => { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
+

Reducer Counters

+ + {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 ( + + + + ); +}; 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} + + )} + +
+
+