Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/time-machine-plugin.md
Original file line number Diff line number Diff line change
@@ -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.
155 changes: 154 additions & 1 deletion apps/e2e-app-vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 (
<div className="flex items-center gap-2">
<span data-testid="reducer-only-count">{state.count}</span>
<button
onClick={() => dispatch({ type: "increment" })}
className="border px-2 py-1 rounded"
data-testid="reducer-only-increment"
>
Increment
</button>
</div>
);
};

// 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 (
<div className="flex items-center gap-2">
<span data-testid="mixed-hooks-count">{state.count}</span>
<button
onClick={() => dispatch({ type: "increment" })}
className="border px-2 py-1 rounded"
data-testid="mixed-hooks-increment"
>
Increment
</button>
<button
onClick={() => setStep((previous) => previous + 1)}
className="border px-2 py-1 rounded"
data-testid="mixed-hooks-step"
>
Step {step}
</button>
</div>
);
};

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 (
<div className="flex items-center gap-2">
<span data-testid="external-store-count">{value}</span>
<button
onClick={() => externalStore.increment()}
className="border px-2 py-1 rounded"
data-testid="external-store-increment"
>
Increment
</button>
</div>
);
};

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 (
<div className="flex items-center gap-2">
<span data-testid="janky-count">{count}</span>
<button
onClick={() => setCount((previous) => previous + 1)}
className="border px-2 py-1 rounded"
data-testid="janky-increment"
>
Janky Increment
</button>
</div>
);
};

const ReducerSection = () => {
const [isExpanded, setIsExpanded] = useState(true);

return (
<section className="border rounded-lg p-4" data-testid="reducer-section">
<h2 className="text-lg font-bold mb-4">Reducer Counters</h2>
<button
onClick={() => setIsExpanded((previous) => !previous)}
className="border px-2 py-1 rounded mb-2"
data-testid="reducer-section-toggle"
>
{isExpanded ? "Collapse" : "Expand"}
</button>
{isExpanded && (
<div className="space-y-2">
<ReducerOnlyCounter />
<MixedHooksCounter />
<ExternalStoreCounter />
<JankyCounter />
</div>
)}
</section>
);
};

const HiddenToggleSection = () => {
const [isVisible, setIsVisible] = useState(true);
const elementRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -754,6 +905,8 @@ export default function App() {

<PointerEventsModalSection />

<ReducerSection />

<HiddenToggleSection />

<div
Expand Down
22 changes: 21 additions & 1 deletion apps/openstory/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { defineConfig } from "vite";
import type { Plugin } from "vite";
import { defineConfig, transformWithOxc } from "vite";
import solid from "vite-plugin-solid";
import { openstory } from "openstory/plugin";

const REACT_FILE_PATTERN = /\.react\.tsx$/;

// The openstory CLI builds an inline vite config with its own optionless
// vite-plugin-solid instance, so the `exclude` passed to the solid() below
// does not stop that duplicate from compiling *.react.tsx files as Solid JSX
// (which crashes at runtime once React renders the result). Compiling the JSX
// with the React runtime here — before any solid plugin runs — leaves no JSX
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// behind for either instance to transform.
const reactFileJsxPlugin = (): Plugin => ({
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],
}),
Expand Down
Loading
Loading