fix(deps): clear all high-severity advisories and fix dependency classification

`npm audit fix` cleared 23 vulnerabilities (14 high) entirely within
existing semver ranges - lockfile only, no package.json version changes.
`npm update` then pulled in-range updates including react 19.2 -> 19.3 and
@base-ui/react 1.2 -> 1.8.

shadcn, tailwindcss, @tailwindcss/vite and tw-animate-css move to
devDependencies. shadcn in particular is a scaffolding CLI with zero
runtime imports that was dragging msw, @modelcontextprotocol/sdk,
@babel/core and ts-morph into the production dependency tree - the tree
`npm ci --omit=dev` installs and the Trivy filesystem scan reads. The
production image is nginx serving dist/, so none of them ever shipped; they
were simply misclassified. The build stage installs all dependencies, so
the production build is unaffected.

The react-hooks plugin update (7.0.1 -> 7.1.1) caught five more
set-state-in-effect cases, all fixed rather than suppressed:

- SearchAutocomplete derives "query too short" and its loading/error state
  from which search has settled, instead of clearing state on every
  keystroke below the threshold; the mode-change reset moves to a
  render-phase adjustment, which also keeps focus that remounting via a
  `key` would have dropped mid-chain.
- Leaderboard uses the same derived request-key pattern as the other list
  pages, and gains the cancellation it was missing.
- Hint visibility is derived from the chain length the hints were fetched
  for, rather than cleared by an effect.
- The async submit guard becomes a ref: as state it was both the guard and
  a dependency, so setting it re-ran its own effect.

Verified in the browser: search, the mode reset, the Base UI dialog and a
full scored game all behave correctly, and the production image builds.
This commit is contained in:
TehRiehlDeal committed 2026-09-09 21:57:32 -07:00
1 parent b29361b03b
commit 72168e0b0a
6 files changed
+2473 -1987

No files matched your search

+2378 -1913
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -18,7 +18,6 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -26,25 +25,26 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router": "^7.13.1",
"shadcn": "^4.0.0",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"commit-and-tag-version": "^12.5.0",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"commit-and-tag-version": "^12.5.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"shadcn": "^4.0.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
+51 -39
View File
@@ -28,8 +28,6 @@ export default function SearchAutocomplete({
}: SearchAutocompleteProps) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [movieResults, setMovieResults] = useState<TmdbMovieResult[]>([]);
const [personResults, setPersonResults] = useState<TmdbPersonResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
@@ -38,67 +36,81 @@ export default function SearchAutocomplete({
const inputRef = useRef<HTMLInputElement>(null);
const listId = `search-listbox-${mode}`;
const results = mode === 'actor' ? personResults : movieResults;
// A query too short to search is a derived condition. Clearing results in an
// effect meant an extra committed render on every keystroke below the
// threshold, and left the stored results and the displayed ones out of step.
const isSearchable = debouncedQuery.trim().length >= 2;
const results = isSearchable
? mode === 'actor'
? personResults
: movieResults
: [];
// Loading and error are derived from which search has settled, so neither is
// set synchronously inside the effect.
const searchKey = `${mode}:${debouncedQuery}`;
const [settled, setSettled] = useState<{
key: string;
error: string | null;
} | null>(null);
const isLoading = isSearchable && settled?.key !== searchKey;
const searchError = settled?.key === searchKey ? settled.error : null;
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2) {
setMovieResults([]);
setPersonResults([]);
setIsOpen(false);
return;
}
if (!isSearchable) return;
let cancelled = false;
setIsLoading(true);
setSearchError(null);
const doSearch = async () => {
void (async () => {
let error: string | null = null;
try {
if (mode === 'actor') {
const data = await searchPersons(debouncedQuery);
if (!cancelled) {
setPersonResults(
data.results.filter(
(p) => p.known_for_department === 'Acting',
),
);
setMovieResults([]);
setIsOpen(true);
setActiveIndex(-1);
}
if (cancelled) return;
setPersonResults(
data.results.filter((p) => p.known_for_department === 'Acting'),
);
setMovieResults([]);
setIsOpen(true);
setActiveIndex(-1);
} else {
const data = await searchMovies(debouncedQuery);
if (!cancelled) {
setMovieResults(data.results);
setPersonResults([]);
setIsOpen(true);
setActiveIndex(-1);
}
if (cancelled) return;
setMovieResults(data.results);
setPersonResults([]);
setIsOpen(true);
setActiveIndex(-1);
}
} catch {
if (!cancelled) {
setSearchError('Search failed. Please try again.');
setIsOpen(true);
}
if (cancelled) return;
error = 'Search failed. Please try again.';
setIsOpen(true);
} finally {
if (!cancelled) setIsLoading(false);
if (!cancelled) setSettled({ key: searchKey, error });
}
};
})();
doSearch();
return () => {
cancelled = true;
};
}, [debouncedQuery, mode]);
}, [debouncedQuery, mode, isSearchable, searchKey]);
// Reset when mode changes
useEffect(() => {
// Reset when the mode flips between actor and movie. Adjusting state during
// render is React's documented pattern for reacting to a changed prop; doing
// it in an effect costs an extra committed render, and remounting via a
// `key` would drop focus in the middle of a chain.
const [prevMode, setPrevMode] = useState(mode);
if (mode !== prevMode) {
setPrevMode(mode);
setQuery('');
setMovieResults([]);
setPersonResults([]);
setIsOpen(false);
setActiveIndex(-1);
// Auto-focus on mode change
}
// Focus is a DOM side effect, so it stays in an effect.
useEffect(() => {
inputRef.current?.focus();
}, [mode]);
+9 -8
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { useGameStore } from '@/stores/game-store';
import { useHint as requestHint } from '@/api/games';
import type { TmdbCastMember } from '@/types';
@@ -7,6 +7,7 @@ const MAX_HINTS = 3;
export function useHints() {
const [hints, setHints] = useState<TmdbCastMember[]>([]);
const [hintsForChainLength, setHintsForChainLength] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,23 +47,23 @@ export function useHints() {
})) as TmdbCastMember[],
);
setHintsUsed(result.hintsUsed);
setHintsForChainLength(chain.length);
} catch {
setError('Could not fetch a hint. Please try again.');
} finally {
setIsLoading(false);
}
}, [canHint, sessionId, setHintsUsed]);
}, [canHint, sessionId, setHintsUsed, chain.length]);
const clearHints = useCallback(() => setHints([]), []);
// Stale hints used to linger after the player picked an actor some other
// way, leaving the previous movie's cast on screen.
useEffect(() => {
setHints([]);
}, [chain.length]);
// Hints belong to the movie they were fetched for. Deriving visibility means
// they disappear as soon as the chain moves on, without an effect that
// clears state and forces a second render.
const visibleHints = hintsForChainLength === chain.length ? hints : [];
return {
hints,
hints: visibleHints,
isLoading,
error,
getHint,
+7 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useGameStore } from '@/stores/game-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -49,7 +49,9 @@ export default function AsyncVersusGame() {
const [mode, setMode] = useState<AsyncMode>('creator');
const [targetChainLength, setTargetChainLength] = useState<number | null>(null);
const [showCompletion, setShowCompletion] = useState(false);
const [submitting, setSubmitting] = useState(false);
// A ref, not state: this only guards re-entry, and setting state here made
// the effect re-run itself.
const submittingRef = useRef(false);
const [creatorScore, setCreatorScore] = useState<{ totalScore: number } | null>(null);
const [myRank, setMyRank] = useState<number | undefined>();
const [error, setError] = useState<string | null>(null);
@@ -133,13 +135,13 @@ export default function AsyncVersusGame() {
!score ||
!matchId ||
!sessionId ||
submitting ||
submittingRef.current ||
showCompletion ||
error
)
return;
setSubmitting(true);
submittingRef.current = true;
(async () => {
try {
@@ -164,7 +166,7 @@ export default function AsyncVersusGame() {
} catch {
setError('Failed to submit score');
} finally {
setSubmitting(false);
submittingRef.current = false;
}
})();
}, [
@@ -172,7 +174,6 @@ export default function AsyncVersusGame() {
score,
matchId,
mode,
submitting,
showCompletion,
sessionId,
user?.id,
+23 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
@@ -44,25 +44,32 @@ export default function Leaderboard() {
const [period, setPeriod] = useState<Period>('daily');
const [difficulty, setDifficulty] = useState<DifficultyFilter>('all');
const [data, setData] = useState<LeaderboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [settledKey, setSettledKey] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const diffParam = difficulty === 'all' ? undefined : difficulty;
const result = await getLeaderboard(period, page, 20, diffParam);
setData(result);
} catch {
setData(null);
} finally {
setLoading(false);
}
}, [period, page, difficulty]);
// Derived rather than a flag set inside the effect: changing period, page or
// difficulty puts the table back into its loading state on the same render
// that requests it, instead of costing an extra committed render.
const requestKey = `${period}:${page}:${difficulty}`;
const loading = settledKey !== requestKey;
useEffect(() => {
fetchData();
}, [fetchData]);
let cancelled = false;
void (async () => {
try {
const diffParam = difficulty === 'all' ? undefined : difficulty;
const result = await getLeaderboard(period, page, 20, diffParam);
if (!cancelled) setData(result);
} catch {
if (!cancelled) setData(null);
} finally {
if (!cancelled) setSettledKey(requestKey);
}
})();
return () => {
cancelled = true;
};
}, [period, page, difficulty, requestKey]);
const handlePeriodChange = (p: Period) => {
setPeriod(p);