diff --git a/apps/e2e-app-vite/src/App.tsx b/apps/e2e-app-vite/src/App.tsx index 17777e504..0e6ed1b8c 100644 --- a/apps/e2e-app-vite/src/App.tsx +++ b/apps/e2e-app-vite/src/App.tsx @@ -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; @@ -756,6 +757,8 @@ export default function App() { + +
count + action.by; + +const ReducerCounter = () => { + const [count, dispatch] = useReducer(counterReducer, 0); + return ( +
+ {count} + +
+ ); +}; + +const TransitionCounter = () => { + const [count, setCount] = useState(0); + const [, startTransition] = useTransition(); + return ( +
+ {count} + +
+ ); +}; + +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 ( +
+ {value} + +
+ ); +}; + +const CountContext = createContext(0); + +const ContextConsumer = () => { + const value = useContext(CountContext); + return {value}; +}; + +const ContextCounter = () => { + const [value, setValue] = useState(0); + return ( + +
+ + +
+
+ ); +}; + +export const FreezeHookHarness = () => { + return ( +
+

Freeze Hook Harness

+
+ + + + +
+
+ ); +}; diff --git a/packages/react-grab/e2e/freeze-hooks.spec.ts b/packages/react-grab/e2e/freeze-hooks.spec.ts new file mode 100644 index 000000000..58ed3c129 --- /dev/null +++ b/packages/react-grab/e2e/freeze-hooks.spec.ts @@ -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 => { + 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 => { + await reactGrab.page.evaluate((id) => { + const button = document.querySelector(`[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 => { + 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(); + }); +});