From 0740f656e64bada9320c40918184ab2371a6853b Mon Sep 17 00:00:00 2001 From: QA Runner Date: Mon, 20 Jul 2026 11:17:24 -0700 Subject: [PATCH] fix: burn down frontend eslint errors; make CI lint blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes frontend/npm run lint from 23 errors / 40 warnings to 0 errors / 40 warnings, then removes continue-on-error from the CI lint step so it gates merges. Errors fixed outright: - @typescript-eslint/no-explicit-any (5, ChatView.tsx): dropped redundant (msg as any) casts — Message already declares files, workflow, and error with the exact shapes the props expect. - react/no-unescaped-entities (1, support/page.tsx): "We'll" -> "We'll". Targeted disables (17, all react-hooks/set-state-in-effect): every site is an intentional effect-driven state pattern — SSR/hydration mount gates (Modal, useSelectedModel), reset-on-prop/identity-change (CaseLawPanel x3, ChatView chat switch, CitationQuotesHeader, ChatHistoryContext logout, useFetchDocxBytes), sync fast paths of async fetch/check effects (CaseLawPanel, MfaLoginGate x2), DOM-measured state (ChatView scroll button, message-visibility restore), and timed UI latches (PreResponseWrapper, TRChatPanel, AskInputPopup auto-submit). Rewriting any of them would change runtime behavior, so each carries a // eslint-disable-next-line with a one-line reason instead of a fix or a repo-wide rule change. CI: lint step is now blocking; comments updated to say the backlog is at zero. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 8 +++----- .../app/components/assistant/AskInputPopup.tsx | 1 + .../src/app/components/assistant/CaseLawPanel.tsx | 4 ++++ .../src/app/components/assistant/ChatView.tsx | 15 +++++++++------ .../components/assistant/CitationQuotesHeader.tsx | 1 + .../components/assistant/PreResponseWrapper.tsx | 1 + frontend/src/app/components/modals/Modal.tsx | 1 + .../src/app/components/shared/MfaLoginGate.tsx | 2 ++ .../src/app/components/tabular/TRChatPanel.tsx | 1 + frontend/src/app/contexts/ChatHistoryContext.tsx | 1 + frontend/src/app/hooks/useFetchDocxBytes.ts | 1 + frontend/src/app/hooks/useSelectedModel.ts | 1 + frontend/src/app/support/page.tsx | 2 +- 13 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa7c276..b14ec8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ # on a tree without those pieces the test steps no-op and the build still # gates the merge. Beyond the fork version this also builds the frontend # (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and -# runs eslint as an advisory step (see the comment on that step). +# runs eslint as a blocking gate (the error backlog is at zero). name: CI on: @@ -61,11 +61,9 @@ jobs: # harness PR merges); runs the suite once it exists. - run: npm test --if-present - # Advisory only for now: main currently carries 23 eslint errors, so a - # blocking gate would turn every PR red on inherited debt. Flip this to - # blocking (remove continue-on-error) once the backlog is burned down. + # Blocking gate: the eslint error backlog was burned down in this PR + # (0 errors; warnings do not fail the step), so any new error fails CI. - run: npm run lint - continue-on-error: true # Production build. NEXT_PUBLIC_* values are inlined at build time and # only need to be well-formed here — nothing is contacted during build. diff --git a/frontend/src/app/components/assistant/AskInputPopup.tsx b/frontend/src/app/components/assistant/AskInputPopup.tsx index 05a20b2..52da7ea 100644 --- a/frontend/src/app/components/assistant/AskInputPopup.tsx +++ b/frontend/src/app/components/assistant/AskInputPopup.tsx @@ -242,6 +242,7 @@ export function AskInputPopup({ }; useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- auto-submit when every question is answered; submit() sets state as part of the side effect if (canSubmit) submit(); }); diff --git a/frontend/src/app/components/assistant/CaseLawPanel.tsx b/frontend/src/app/components/assistant/CaseLawPanel.tsx index e60e536..0be4ea7 100644 --- a/frontend/src/app/components/assistant/CaseLawPanel.tsx +++ b/frontend/src/app/components/assistant/CaseLawPanel.tsx @@ -216,6 +216,7 @@ export function CaseLawPanel({ useEffect(() => { if (tab.opinions?.length) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync path of an async fetch effect: serve prop/cache data without a loading flash setOpinions(tab.opinions); setLoading(false); setError(null); @@ -269,10 +270,12 @@ export function CaseLawPanel({ orderOpinions(opinions).find( ({ opinion }) => typeof opinion.opinionId === "number", )?.opinion.opinionId ?? null; + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset active opinion after opinions load setActiveOpinionId(firstOpinionId); }, [opinions]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync quote list when the tab prop changes setRelevantQuotes(tab.quotes ?? []); }, [tab.quotes]); @@ -321,6 +324,7 @@ export function CaseLawPanel({ ); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset quote selection when the quote set changes setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 }); const firstQuote = relevantQuotes[0]; setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null); diff --git a/frontend/src/app/components/assistant/ChatView.tsx b/frontend/src/app/components/assistant/ChatView.tsx index f49e7e4..92591cb 100644 --- a/frontend/src/app/components/assistant/ChatView.tsx +++ b/frontend/src/app/components/assistant/ChatView.tsx @@ -83,6 +83,7 @@ export function ChatView({ const panelCloseTimerRef = useRef(null); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset per-chat UI state when switching chats setHiddenAskInputKeys(new Set()); }, [chatId]); @@ -519,6 +520,7 @@ export function ChatView({ const c = messagesContainerRef.current; if (!c) return; c.addEventListener("scroll", updateScrollButton); + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial scroll-button state must be measured from the live DOM updateScrollButton(); return () => c.removeEventListener("scroll", updateScrollButton); }, [messages, updateScrollButton]); @@ -553,6 +555,7 @@ export function ChatView({ useEffect(() => { if (messages.length === 0) { hasScrolledRef.current = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- hide messages until scroll position is restored to avoid a visible jump setMessagesVisible(false); } else if (!hasScrolledRef.current) { const userMsgCount = messages.filter( @@ -682,8 +685,8 @@ export function ChatView({ {msg.role === "user" ? ( ) : ( { if (!hasMultipleQuotes && viewMode === "list") { + // eslint-disable-next-line react-hooks/set-state-in-effect -- collapse list view when quotes drop to a single item setViewMode("single"); } }, [hasMultipleQuotes, viewMode]); diff --git a/frontend/src/app/components/assistant/PreResponseWrapper.tsx b/frontend/src/app/components/assistant/PreResponseWrapper.tsx index 77f3255..e27c45a 100644 --- a/frontend/src/app/components/assistant/PreResponseWrapper.tsx +++ b/frontend/src/app/components/assistant/PreResponseWrapper.tsx @@ -29,6 +29,7 @@ export function PreResponseWrapper({ useEffect(() => { if (forceOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- streaming open/minimize latch (see comment above) setIsOpen(true); return; } diff --git a/frontend/src/app/components/modals/Modal.tsx b/frontend/src/app/components/modals/Modal.tsx index cacffcd..30a1b62 100644 --- a/frontend/src/app/components/modals/Modal.tsx +++ b/frontend/src/app/components/modals/Modal.tsx @@ -61,6 +61,7 @@ export function Modal({ // Portals can't render during SSR, so a keep-mounted modal only renders // (hidden) after the first client mount. const [hasMounted, setHasMounted] = useState(false); + // eslint-disable-next-line react-hooks/set-state-in-effect -- SSR portal gate: must flip after first client mount useEffect(() => setHasMounted(true), []); const hasHeader = breadcrumbs?.length; const hasFooter = diff --git a/frontend/src/app/components/shared/MfaLoginGate.tsx b/frontend/src/app/components/shared/MfaLoginGate.tsx index bf0f367..93718c6 100644 --- a/frontend/src/app/components/shared/MfaLoginGate.tsx +++ b/frontend/src/app/components/shared/MfaLoginGate.tsx @@ -21,6 +21,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { useEffect(() => { if (!user) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- sync fast paths of the async MFA check effect setGateState("idle"); return; } @@ -64,6 +65,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) { if (gateState === "required" && !isVerifyPage) { if (hasRecentMfaVerification()) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear gate when a recent MFA verification exists instead of redirecting setGateState("verified"); return; } diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 3ab5163..9119953 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -164,6 +164,7 @@ function TRResponseStatus({ isActive }: { isActive: boolean }) { useEffect(() => { if (wasActiveRef.current && !isActive) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- timed 'Done' flash on the active->idle transition setShowDone(true); setDoneVisible(true); const t = setTimeout(() => setDoneVisible(false), 1500); diff --git a/frontend/src/app/contexts/ChatHistoryContext.tsx b/frontend/src/app/contexts/ChatHistoryContext.tsx index 718fc5f..2bca6d4 100644 --- a/frontend/src/app/contexts/ChatHistoryContext.tsx +++ b/frontend/src/app/contexts/ChatHistoryContext.tsx @@ -73,6 +73,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!user) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear chat state on logout inside the effect that loads chats setChats([]); setChatLimit(INITIAL_CHAT_LIMIT); setHasMoreChats(false); diff --git a/frontend/src/app/hooks/useFetchDocxBytes.ts b/frontend/src/app/hooks/useFetchDocxBytes.ts index f74bf47..d7cccfe 100644 --- a/frontend/src/app/hooks/useFetchDocxBytes.ts +++ b/frontend/src/app/hooks/useFetchDocxBytes.ts @@ -49,6 +49,7 @@ export function useFetchDocxBytes( useEffect(() => { if (!documentId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale bytes when documentId is removed, within the fetch effect setBytes(null); setDownloadUrl(null); return; diff --git a/frontend/src/app/hooks/useSelectedModel.ts b/frontend/src/app/hooks/useSelectedModel.ts index 93a637f..5b5530e 100644 --- a/frontend/src/app/hooks/useSelectedModel.ts +++ b/frontend/src/app/hooks/useSelectedModel.ts @@ -16,6 +16,7 @@ export function useSelectedModel(): [string, (id: string) => void] { const [model, setModelState] = useState(DEFAULT_MODEL_ID); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- hydration-safe localStorage read; SSR must render the default model setModelState(readStored()); }, []); diff --git a/frontend/src/app/support/page.tsx b/frontend/src/app/support/page.tsx index 08b08d4..b405d92 100644 --- a/frontend/src/app/support/page.tsx +++ b/frontend/src/app/support/page.tsx @@ -228,7 +228,7 @@ export default function SupportPage() { {/* Email Display (if logged in) */} {user?.email && (
- We'll respond to:{" "} + We'll respond to:{" "} {user.email}