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:
1 parent
b29361b03b
commit
72168e0b0a
6 files changed
+2473
-1987
No files matched your search
Generated
+2378
-1913
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user