Przeglądaj źródła

[Phase 4 U3] Harden useRoll with reduced motion, pool capture, and Strict Mode cleanup

- Detect prefers-reduced-motion via useSyncExternalStore (SSR-safe pattern
  mirrored from carousel-animation.tsx).
- Reduced-motion fast path: roll() jumps straight to "complete" with no timer.
- Capture the pool snapshot by value at call time so concurrent real-time cache
  mutations cannot change the in-flight winner.
- Re-roll semantics: roll() without a pool reuses the last captured snapshot.
- Timer is always cleared on unmount / re-roll to survive React 19 Strict Mode
  double-invocation.
- New unit tests cover state transitions, reduced-motion branch, pool capture
  invariant, re-roll, and Strict Mode cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User 2 miesięcy temu
rodzic
commit
4e69a2d70e
2 zmienionych plików z 321 dodań i 14 usunięć
  1. 255 0
      src/__tests__/hooks/use-roll.test.ts
  2. 66 14
      src/hooks/use-roll.ts

+ 255 - 0
src/__tests__/hooks/use-roll.test.ts

@@ -0,0 +1,255 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { StrictMode, createElement, type ReactNode } from "react";
+import { useRoll } from "@/hooks/use-roll";
+import type { Movie } from "@/types/movie";
+
+function makeMovie(id: string, watched = false): Movie {
+  return {
+    id,
+    group_id: "g1",
+    tmdb_id: Number(id) || 0,
+    title: `Movie ${id}`,
+    year: 2020,
+    poster_path: null,
+    genres: [],
+    trailer_url: null,
+    trailer_url_refreshed_at: null,
+    metadata_refreshed_at: null,
+    added_by: null,
+    watched,
+    watched_at: null,
+    added_at: "2026-01-01T00:00:00Z",
+  };
+}
+
+interface MatchMediaListener {
+  (event: { matches: boolean }): void;
+}
+
+interface FakeMediaQueryList {
+  matches: boolean;
+  media: string;
+  addEventListener: (type: "change", listener: MatchMediaListener) => void;
+  removeEventListener: (type: "change", listener: MatchMediaListener) => void;
+}
+
+function installMatchMedia(matches: boolean) {
+  const listeners = new Set<MatchMediaListener>();
+  const mql: FakeMediaQueryList = {
+    matches,
+    media: "(prefers-reduced-motion: reduce)",
+    addEventListener: (_type, listener) => listeners.add(listener),
+    removeEventListener: (_type, listener) => listeners.delete(listener),
+  };
+  vi.stubGlobal(
+    "matchMedia",
+    vi.fn(() => mql),
+  );
+  return mql;
+}
+
+const StrictWrapper = ({ children }: { children: ReactNode }) =>
+  createElement(StrictMode, null, children);
+
+describe("useRoll", () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    installMatchMedia(false);
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.unstubAllGlobals();
+    vi.restoreAllMocks();
+  });
+
+  it("transitions idle -> rolling -> complete with the 2500ms timer", () => {
+    const { result } = renderHook(() => useRoll());
+    const pool = [makeMovie("1"), makeMovie("2"), makeMovie("3")];
+
+    expect(result.current.rollState).toBe("idle");
+    expect(result.current.result).toBeNull();
+
+    act(() => {
+      result.current.roll(pool);
+    });
+
+    expect(result.current.rollState).toBe("rolling");
+    expect(result.current.result).not.toBeNull();
+
+    act(() => {
+      vi.advanceTimersByTime(2499);
+    });
+    expect(result.current.rollState).toBe("rolling");
+
+    act(() => {
+      vi.advanceTimersByTime(1);
+    });
+    expect(result.current.rollState).toBe("complete");
+  });
+
+  it("reset returns to idle and clears the result", () => {
+    const { result } = renderHook(() => useRoll());
+    const pool = [makeMovie("1"), makeMovie("2")];
+
+    act(() => {
+      result.current.roll(pool);
+    });
+    act(() => {
+      vi.advanceTimersByTime(2500);
+    });
+    expect(result.current.rollState).toBe("complete");
+
+    act(() => {
+      result.current.reset();
+    });
+    expect(result.current.rollState).toBe("idle");
+    expect(result.current.result).toBeNull();
+  });
+
+  it("reduced-motion fast path: roll() goes straight to complete with no timer", () => {
+    installMatchMedia(true);
+
+    const { result } = renderHook(() => useRoll());
+    const pool = [makeMovie("1"), makeMovie("2")];
+
+    act(() => {
+      result.current.roll(pool);
+    });
+
+    // Synchronously complete — no timer advance needed.
+    expect(result.current.rollState).toBe("complete");
+    expect(result.current.result).not.toBeNull();
+    // No pending timers should remain.
+    expect(vi.getTimerCount()).toBe(0);
+  });
+
+  it("captures the pool by value at call time (real-time cache mutations are ignored)", () => {
+    const { result } = renderHook(() => useRoll());
+
+    // Build a pool of distinct movies; mutate the source array after roll().
+    const original = [makeMovie("a"), makeMovie("b"), makeMovie("c")];
+    const originalIds = new Set(original.map((m) => m.id));
+    const pool = [...original];
+
+    act(() => {
+      result.current.roll(pool);
+    });
+
+    // Mutate aggressively: clear and replace with a totally different set.
+    pool.splice(0, pool.length);
+    pool.push(makeMovie("X"), makeMovie("Y"), makeMovie("Z"));
+
+    act(() => {
+      vi.advanceTimersByTime(2500);
+    });
+
+    expect(result.current.rollState).toBe("complete");
+    const winner = result.current.result;
+    expect(winner).not.toBeNull();
+    // Winner must come from the original snapshot, not the mutated array.
+    expect(originalIds.has(winner!.id)).toBe(true);
+  });
+
+  it("re-roll: calling roll() again from complete returns to rolling", () => {
+    const { result } = renderHook(() => useRoll());
+    const pool = [makeMovie("1"), makeMovie("2"), makeMovie("3")];
+
+    act(() => {
+      result.current.roll(pool);
+    });
+    act(() => {
+      vi.advanceTimersByTime(2500);
+    });
+    expect(result.current.rollState).toBe("complete");
+
+    // Re-roll without supplying a new pool: must reuse the captured snapshot.
+    act(() => {
+      result.current.roll();
+    });
+    expect(result.current.rollState).toBe("rolling");
+    expect(result.current.result).not.toBeNull();
+
+    act(() => {
+      vi.advanceTimersByTime(2500);
+    });
+    expect(result.current.rollState).toBe("complete");
+  });
+
+  it("re-roll with a fresh pool overrides the captured snapshot", () => {
+    const { result } = renderHook(() => useRoll());
+
+    act(() => {
+      result.current.roll([makeMovie("1")]);
+    });
+    act(() => {
+      vi.advanceTimersByTime(2500);
+    });
+
+    const onlySurvivor = makeMovie("only");
+    act(() => {
+      result.current.roll([onlySurvivor]);
+    });
+    act(() => {
+      vi.advanceTimersByTime(2500);
+    });
+
+    expect(result.current.result?.id).toBe("only");
+  });
+
+  it("Strict Mode: unmounting before the timer fires cancels it (no late state updates)", () => {
+    const warn = vi.spyOn(console, "error").mockImplementation(() => {});
+
+    const { result, unmount } = renderHook(() => useRoll(), {
+      wrapper: StrictWrapper,
+    });
+
+    act(() => {
+      result.current.roll([makeMovie("1"), makeMovie("2")]);
+    });
+
+    unmount();
+
+    // Advance well past the animation duration.
+    act(() => {
+      vi.advanceTimersByTime(10_000);
+    });
+
+    // No queued timers should remain after unmount + advance.
+    expect(vi.getTimerCount()).toBe(0);
+    // No "state update on unmounted component" warnings (Strict Mode double-invocation safe).
+    const stateUpdateWarning = warn.mock.calls.find((call) =>
+      String(call[0] ?? "").includes("unmounted"),
+    );
+    expect(stateUpdateWarning).toBeUndefined();
+  });
+
+  it("calling roll() again mid-rolling cancels the previous timer", () => {
+    const { result } = renderHook(() => useRoll());
+    const pool = [makeMovie("1"), makeMovie("2")];
+
+    act(() => {
+      result.current.roll(pool);
+    });
+    act(() => {
+      vi.advanceTimersByTime(1000);
+    });
+
+    // Start a new roll mid-flight; previous timer must be cancelled.
+    act(() => {
+      result.current.roll(pool);
+    });
+    expect(vi.getTimerCount()).toBe(1);
+
+    act(() => {
+      vi.advanceTimersByTime(2499);
+    });
+    expect(result.current.rollState).toBe("rolling");
+
+    act(() => {
+      vi.advanceTimersByTime(1);
+    });
+    expect(result.current.rollState).toBe("complete");
+  });
+});

+ 66 - 14
src/hooks/use-roll.ts

@@ -1,6 +1,6 @@
 "use client";
 
-import { useState, useCallback, useRef, useEffect } from "react";
+import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from "react";
 import type { Movie } from "@/types/movie";
 import { selectRandomMovie } from "@/lib/dice/randomizer";
 
@@ -8,39 +8,91 @@ export type RollState = "idle" | "rolling" | "complete";
 
 const ANIMATION_DURATION_MS = 2500;
 
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribeToReducedMotion(callback: () => void) {
+  const mq = window.matchMedia(REDUCED_MOTION_QUERY);
+  mq.addEventListener("change", callback);
+  return () => mq.removeEventListener("change", callback);
+}
+
+function getReducedMotionSnapshot() {
+  return window.matchMedia(REDUCED_MOTION_QUERY).matches;
+}
+
+function getReducedMotionServerSnapshot() {
+  return false;
+}
+
+function usePrefersReducedMotion(): boolean {
+  return useSyncExternalStore(
+    subscribeToReducedMotion,
+    getReducedMotionSnapshot,
+    getReducedMotionServerSnapshot,
+  );
+}
+
 export interface UseRollReturn {
   result: Movie | null;
   rollState: RollState;
-  roll: (eligibleMovies: Movie[]) => void;
+  /** Omit `eligibleMovies` to re-roll from the previously captured snapshot. */
+  roll: (eligibleMovies?: Movie[]) => void;
   reset: () => void;
 }
 
 export function useRoll(): UseRollReturn {
   const [result, setResult] = useState<Movie | null>(null);
   const [rollState, setRollState] = useState<RollState>("idle");
-  const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const capturedPoolRef = useRef<Movie[]>([]);
+
+  const prefersReducedMotion = usePrefersReducedMotion();
 
   useEffect(() => {
     return () => {
-      if (timerRef.current) clearTimeout(timerRef.current);
+      if (timerRef.current) {
+        clearTimeout(timerRef.current);
+        timerRef.current = null;
+      }
     };
   }, []);
 
-  const roll = useCallback((eligibleMovies: Movie[]) => {
-    if (timerRef.current) clearTimeout(timerRef.current);
+  const roll = useCallback(
+    (eligibleMovies?: Movie[]) => {
+      if (timerRef.current) {
+        clearTimeout(timerRef.current);
+        timerRef.current = null;
+      }
 
-    const winner = selectRandomMovie(eligibleMovies);
+      // Capture by value so concurrent real-time cache mutations to the upstream
+      // array cannot change the in-flight winner.
+      const snapshot =
+        eligibleMovies !== undefined ? [...eligibleMovies] : capturedPoolRef.current;
+      capturedPoolRef.current = snapshot;
 
-    setRollState("rolling");
-    setResult(winner);
+      const winner = selectRandomMovie(snapshot);
+      setResult(winner);
 
-    timerRef.current = setTimeout(() => {
-      setRollState("complete");
-    }, ANIMATION_DURATION_MS);
-  }, []);
+      if (prefersReducedMotion) {
+        setRollState("complete");
+        return;
+      }
+
+      setRollState("rolling");
+      timerRef.current = setTimeout(() => {
+        setRollState("complete");
+        timerRef.current = null;
+      }, ANIMATION_DURATION_MS);
+    },
+    [prefersReducedMotion],
+  );
 
   const reset = useCallback(() => {
-    if (timerRef.current) clearTimeout(timerRef.current);
+    if (timerRef.current) {
+      clearTimeout(timerRef.current);
+      timerRef.current = null;
+    }
+    capturedPoolRef.current = [];
     setResult(null);
     setRollState("idle");
   }, []);