Skip to content
Closed
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>
);
};
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 }) => {
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();
});
});
Loading