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
3 changes: 3 additions & 0 deletions apps/e2e-app-vite/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { PerfGrid } from "./perf-grid";
import { FreezeHookHarness } from "./freeze-hook-harness";

interface Todo {
id: number;
Expand Down Expand Up @@ -756,6 +757,8 @@ export default function App() {

<HiddenToggleSection />

<FreezeHookHarness />

<div
className="h-96 flex items-center justify-center bg-gray-100 rounded-lg"
data-testid="spacer-section"
Expand Down
128 changes: 128 additions & 0 deletions apps/e2e-app-vite/src/freeze-hook-harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
createContext,
useContext,
useReducer,
useState,
useSyncExternalStore,
useTransition,
} from "react";

// e2e fixture that drives react-grab's freeze dispatcher patching for hooks
// beyond plain useState: useReducer, useTransition, useSyncExternalStore, and
// context dependencies. freeze-hooks.spec.ts freezes the page, bumps each
// counter, and asserts the displayed value holds while frozen and resumes
// afterwards. (The transition/context counters still update via useState
// internally; the point is to exercise those dispatcher/queue paths.)
interface CounterAction {
by: number;
}

const counterReducer = (count: number, action: CounterAction): number => count + action.by;

const ReducerCounter = () => {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div className="flex items-center gap-2" data-testid="reducer-counter">
<span data-testid="reducer-count">{count}</span>
<button
type="button"
className="border px-2 py-1 rounded"
onClick={() => dispatch({ by: 1 })}
data-testid="reducer-increment"
>
Reducer +1
</button>
</div>
);
};

const TransitionCounter = () => {
const [count, setCount] = useState(0);
const [, startTransition] = useTransition();
return (
<div className="flex items-center gap-2" data-testid="transition-counter">
<span data-testid="transition-count">{count}</span>
<button
type="button"
className="border px-2 py-1 rounded"
onClick={() => startTransition(() => setCount((previous) => previous + 1))}
data-testid="transition-increment"
>
Transition +1
</button>
</div>
);
};

let externalStoreValue = 0;
const externalStoreListeners = new Set<() => void>();
const externalStore = {
subscribe: (listener: () => void) => {
externalStoreListeners.add(listener);
return () => {
externalStoreListeners.delete(listener);
};
},
getSnapshot: () => externalStoreValue,
increment: () => {
externalStoreValue += 1;
for (const listener of externalStoreListeners) listener();
},
};

const ExternalStoreCounter = () => {
const value = useSyncExternalStore(externalStore.subscribe, externalStore.getSnapshot);
return (
<div className="flex items-center gap-2" data-testid="store-counter">
<span data-testid="store-count">{value}</span>
<button
type="button"
className="border px-2 py-1 rounded"
onClick={() => externalStore.increment()}
data-testid="store-increment"
>
Store +1
</button>
</div>
);
};

const CountContext = createContext(0);

const ContextConsumer = () => {
const value = useContext(CountContext);
return <span data-testid="context-count">{value}</span>;
};

const ContextCounter = () => {
const [value, setValue] = useState(0);
return (
<CountContext.Provider value={value}>
<div className="flex items-center gap-2" data-testid="context-counter">
<ContextConsumer />
<button
type="button"
className="border px-2 py-1 rounded"
onClick={() => setValue((previous) => previous + 1)}
data-testid="context-increment"
>
Context +1
</button>
</div>
</CountContext.Provider>
);
};

export const FreezeHookHarness = () => {
return (
<section className="border rounded-lg p-4" data-testid="freeze-hooks-section">
<h2 className="text-lg font-bold mb-4">Freeze Hook Harness</h2>
<div className="space-y-3">
<ReducerCounter />
<TransitionCounter />
<ExternalStoreCounter />
<ContextCounter />
</div>
</section>
);
};
4 changes: 3 additions & 1 deletion apps/e2e-app-vite/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { init, formatElementInfo } from "react-grab";
import { init, formatElementInfo, startTraceMode } from "react-grab";
import "./index.css";
import App from "./App.tsx";

declare global {
interface Window {
initReactGrab: typeof init;
formatElementInfo: typeof formatElementInfo;
startTraceMode: typeof startTraceMode;
}
}

window.initReactGrab = init;
window.formatElementInfo = formatElementInfo;
window.startTraceMode = startTraceMode;

createRoot(document.getElementById("root")!).render(
<StrictMode>
Expand Down
104 changes: 104 additions & 0 deletions packages/react-grab/e2e/freeze-hooks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { test, expect, type ReactGrabPageObject } from "./fixtures.js";

// Covers react-grab's freeze dispatcher patching for hooks beyond useState. The
// FreezeHookHarness fixture exposes a useReducer, useTransition,
// useSyncExternalStore, and context-backed counter; each holds its displayed
// value while the page is frozen (prompt mode) and resumes afterwards. This
// drives the freeze-updates.ts pause/resume paths the useState-only
// freeze-updates.spec never reaches, notably context-dependency pause/resume.

const FREEZE_TARGET = "[data-testid='dynamic-element-1']";

// Async React work scheduled by a click needs a beat to (not) commit before we
// read, so the freeze-hold assertion can actually catch a leaked update and the
// post-unfreeze baseline is stable. Mirrors freeze-updates.spec's settle waits.
const FREEZE_SETTLE_MS = 150;

const readCount = async (reactGrab: ReactGrabPageObject, testId: string): Promise<number> => {
const text = await reactGrab.page.locator(`[data-testid='${testId}']`).textContent();
if (text === null) throw new Error(`Counter "${testId}" not found`);
return Number(text.trim());
};

// While react-grab is active its overlay intercepts pointer events, so a real
// click would be swallowed. Dispatch a synthetic DOM click straight to the
// element instead, matching the approach in freeze-updates.spec.
const clickByTestId = async (reactGrab: ReactGrabPageObject, testId: string): Promise<void> => {
await reactGrab.page.evaluate((id) => {
const button = document.querySelector<HTMLElement>(`[data-testid='${id}']`);
button?.click();
}, testId);
};

// Verifies a hook-driven counter is frozen while the page is frozen, then bumps
// normally once unfrozen. `countTestId` shows the value; `incrementTestId` bumps
// it.
const assertFreezeHoldsThenResumes = async (
reactGrab: ReactGrabPageObject,
countTestId: string,
incrementTestId: string,
): Promise<void> => {
const before = await readCount(reactGrab, countTestId);

await reactGrab.enterPromptMode(FREEZE_TARGET);
await clickByTestId(reactGrab, incrementTestId);
await clickByTestId(reactGrab, incrementTestId);
await reactGrab.page.waitForTimeout(FREEZE_SETTLE_MS);

// Frozen: the displayed value must not move while the page is frozen.
expect(await readCount(reactGrab, countTestId)).toBe(before);

await reactGrab.pressEscape();
await reactGrab.deactivate();
await reactGrab.page.waitForTimeout(FREEZE_SETTLE_MS);

// Unfrozen: a fresh bump increments from whatever value settled after
// unfreeze, proving the hook queue was restored cleanly.
const afterUnfreeze = await readCount(reactGrab, countTestId);
await clickByTestId(reactGrab, incrementTestId);
await expect.poll(() => readCount(reactGrab, countTestId)).toBe(afterUnfreeze + 1);
};

test.describe("Freeze Hook Buffering", () => {
test.beforeEach(async ({ reactGrab }) => {
await reactGrab.registerCommentAction();
});

test("useReducer counter freezes then resumes", async ({ reactGrab }) => {
await assertFreezeHoldsThenResumes(reactGrab, "reducer-count", "reducer-increment");
});

test("useTransition counter freezes then resumes", async ({ reactGrab }) => {
await assertFreezeHoldsThenResumes(reactGrab, "transition-count", "transition-increment");
});

test("useSyncExternalStore counter freezes then resumes", async ({ reactGrab }) => {
await assertFreezeHoldsThenResumes(reactGrab, "store-count", "store-increment");
});

test("context-consumer value freezes then resumes", async ({ reactGrab }) => {
await assertFreezeHoldsThenResumes(reactGrab, "context-count", "context-increment");
});

test("all hook counters stay frozen together during one freeze cycle", async ({ reactGrab }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Combined test verifies freeze but never verifies resume — if a multi-hook interaction bug (e.g. context-dependency interfere with useReducer/useTransition replay) prevents clean unfreeze, this test won't catch it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/e2e/freeze-hooks.spec.ts, line 83:

<comment>Combined test verifies freeze but never verifies resume — if a multi-hook interaction bug (e.g. context-dependency interfere with useReducer/useTransition replay) prevents clean unfreeze, this test won't catch it.</comment>

<file context>
@@ -0,0 +1,104 @@
+    await assertFreezeHoldsThenResumes(reactGrab, "context-count", "context-increment");
+  });
+
+  test("all hook counters stay frozen together during one freeze cycle", async ({ reactGrab }) => {
+    const reducerBefore = await readCount(reactGrab, "reducer-count");
+    const transitionBefore = await readCount(reactGrab, "transition-count");
</file context>

const reducerBefore = await readCount(reactGrab, "reducer-count");
const transitionBefore = await readCount(reactGrab, "transition-count");
const storeBefore = await readCount(reactGrab, "store-count");
const contextBefore = await readCount(reactGrab, "context-count");

await reactGrab.enterPromptMode(FREEZE_TARGET);
await clickByTestId(reactGrab, "reducer-increment");
await clickByTestId(reactGrab, "transition-increment");
await clickByTestId(reactGrab, "store-increment");
await clickByTestId(reactGrab, "context-increment");
await reactGrab.page.waitForTimeout(FREEZE_SETTLE_MS);

expect(await readCount(reactGrab, "reducer-count")).toBe(reducerBefore);
expect(await readCount(reactGrab, "transition-count")).toBe(transitionBefore);
expect(await readCount(reactGrab, "store-count")).toBe(storeBefore);
expect(await readCount(reactGrab, "context-count")).toBe(contextBefore);

await reactGrab.pressEscape();
await reactGrab.deactivate();
});
});
69 changes: 69 additions & 0 deletions packages/react-grab/e2e/keyboard-handler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { test, expect } from "./fixtures.js";

// Mirrors WINDOW_REFOCUS_GRACE_PERIOD_MS in src/constants.ts. After the window
// regains focus, activation keys are ignored for this long so the modifiers used
// to alt-tab back don't accidentally activate the overlay.
const WINDOW_REFOCUS_GRACE_PERIOD_MS = 200;

test.describe("Global keyboard handler", () => {
test.describe("Context-menu key", () => {
test("opens the context menu via the ContextMenu key on the hovered selection", async ({
reactGrab,
}) => {
await reactGrab.activate();
await reactGrab.hoverUntilSelected("li");

await reactGrab.pressKey("ContextMenu");

await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true);
});

test("opens the context menu via Shift+F10", async ({ reactGrab }) => {
await reactGrab.activate();
await reactGrab.hoverUntilSelected("li");

await reactGrab.pressKeyCombo(["Shift"], "F10");

await expect.poll(() => reactGrab.isContextMenuVisible(), { timeout: 5000 }).toBe(true);
});

test("ignores the ContextMenu key while inactive", async ({ reactGrab }) => {
expect(await reactGrab.isOverlayVisible()).toBe(false);

await reactGrab.pressKey("ContextMenu");
await reactGrab.page.waitForTimeout(200);

expect(await reactGrab.isContextMenuVisible()).toBe(false);
});
});

test.describe("Window-refocus grace period", () => {
test("suppresses keyboard activation immediately after the window regains focus", async ({
reactGrab,
}) => {
expect(await reactGrab.isOverlayVisible()).toBe(false);

// Hold the modifier first (harmless on its own), then fire the focus event
// so only the single activation keydown needs to land inside the grace
// window — keeping the assertion robust against slow CI round-trips.
await reactGrab.page.keyboard.down(reactGrab.modifierKey);
await reactGrab.page.evaluate(() => window.dispatchEvent(new Event("focus")));
await reactGrab.page.keyboard.down("c");
await reactGrab.page.waitForTimeout(500);

expect(await reactGrab.isOverlayVisible()).toBe(false);

await reactGrab.page.keyboard.up("c");
await reactGrab.page.keyboard.up(reactGrab.modifierKey);
});

test("allows keyboard activation once the grace period elapses", async ({ reactGrab }) => {
await reactGrab.page.evaluate(() => window.dispatchEvent(new Event("focus")));
await reactGrab.page.waitForTimeout(WINDOW_REFOCUS_GRACE_PERIOD_MS + 100);

await reactGrab.activateViaKeyboard();

expect(await reactGrab.isOverlayVisible()).toBe(true);
});
});
});
1 change: 1 addition & 0 deletions packages/react-grab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
export { commentPlugin } from "./core/plugins/comment.js";
export { openPlugin } from "./core/plugins/open.js";
export { generateSnippet } from "./utils/generate-snippet.js";
export { startTraceMode } from "./trace/trace-mode.js";
export type {
Options,
ReactGrabAPI,
Expand Down
18 changes: 18 additions & 0 deletions packages/react-grab/src/trace/download-clip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const triggerDownload = (url: string, fileName: string): void => {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
};

export const downloadBlob = (blob: Blob, fileName: string): void => {
const url = URL.createObjectURL(blob);
triggerDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 0);
};

export const downloadDataUrl = (dataUrl: string, fileName: string): void => {
triggerDownload(dataUrl, fileName);
};
Loading
Loading