From e8f3ed9ffe3451a1d57dbd3995788dcf44d2fb9d Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Wed, 15 Jul 2026 20:54:37 +0300 Subject: [PATCH] frontend: clear the 99 lint errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run lint` reported 99 errors and 26 warnings across 65 files and had presumably been failing for a while — next.config.ts sets eslint.ignoreDuringBuilds, so the build never surfaced it. 68 were in vendored code: components/charts and components/ai-elements, pulled from upstream registries. Re-linting those reports upstream's style back at us, and "fixing" them means diverging and eating conflicts on every update — ai-elements already has exactly this carve-out on the TypeScript side via ignoreBuildErrors. Ignore both, plus the two registry files in components/ui (carousel publishes its api from an effect; the sidebar skeleton picks a random width). The other 31 were ours, nearly all react-hooks/set-state-in-effect on the same shape: an effect that re-seeds form state when a dialog opens or a selection changes. Moved to render-phase adjustment, which is both what React recommends and a real fix — the effect version paints one frame of the *previous* record's values before correcting itself. Two carried sharper bugs: the employee dialog could keep a typed password across a switch to another member, and use-wallet-sync could carry `linked` over to a newly-selected patient, briefly offering to push a record to someone else's wallet. The rest: useIsMobile and speech-support detection become useSyncExternalStore (correct on first paint, no mount flash); refs mirroring state are written in effects rather than during render; the care-team fetch moves into its effect behind a reload key, dropping an exhaustive-deps suppression. Two effects in chat-panel keep the rule disabled with a reason. Both are what effects are for: draining the queued-message buffer when the transport goes idle, and resolving ?thread from the URL — the latter mints an id with nanoid(), so moving it into render would just trade this error for a purity one. Also removes a dead /explore-era import and unused directives found on the way. lint now exits 0, with the ai-elements tsc carve-out unchanged. Co-Authored-By: Claude Opus 4.8 --- .../appointments/appointment-detail-sheet.tsx | 11 +-- .../appointments/calendar-dialog.tsx | 25 ++++--- frontend/components/chat/chat-input.tsx | 17 +++-- frontend/components/chat/chat-panel.tsx | 26 ++++++- .../components/chat/record-edit-dialog.tsx | 14 ++-- frontend/components/graph/record-graph.tsx | 2 +- .../invoices/invoice-detail-sheet.tsx | 11 ++- .../invoices/invoice-form-dialog.tsx | 16 +++-- frontend/components/meetings/meeting-room.tsx | 1 - .../components/meetings/meetings-view.tsx | 21 +++--- .../meetings/schedule-meeting-dialog.tsx | 17 +++-- .../components/meetings/use-audio-level.ts | 14 ++-- .../components/messages/messages-view.tsx | 10 ++- frontend/components/notes/notes-view.tsx | 3 + .../patients/import-from-wallet-dialog.tsx | 14 ++-- .../patients/patient-detail-sheet.tsx | 30 ++++++-- .../components/patients/patients-view.tsx | 6 +- .../patients/transfer-patient-dialog.tsx | 13 +++- .../prescriptions/add-prescription-dialog.tsx | 16 ++++- .../settings/employee-detail-dialog.tsx | 13 +++- .../settings/settings-care-team.tsx | 69 +++++++++++-------- .../components/settings/settings-portal.tsx | 14 ++-- .../settings/settings-preferences.tsx | 10 ++- frontend/components/tasks/tasks-view.tsx | 16 +++-- frontend/components/ui/timeline.tsx | 5 +- frontend/components/wallet/use-wallet-sync.ts | 16 +++-- frontend/eslint.config.mjs | 20 ++++++ frontend/hooks/use-mobile.ts | 31 +++++---- 28 files changed, 325 insertions(+), 136 deletions(-) diff --git a/frontend/components/appointments/appointment-detail-sheet.tsx b/frontend/components/appointments/appointment-detail-sheet.tsx index ca74645..f8ab4f8 100644 --- a/frontend/components/appointments/appointment-detail-sheet.tsx +++ b/frontend/components/appointments/appointment-detail-sheet.tsx @@ -87,15 +87,18 @@ export function AppointmentDetailSheet({ const [status, setStatus] = useState("confirmed"); const [busy, setBusy] = useState(false); - // Seed the form from the selected appointment whenever it changes. - useEffect(() => { - if (!appt) return; + // Seed the form from the selected appointment whenever it changes. Adjusted + // during render rather than in an effect, so the sheet never paints one frame + // of the previous appointment's values. + const [prevAppt, setPrevAppt] = useState(appt); + if (appt && prevAppt !== appt) { + setPrevAppt(appt); setDate(new Date(`${appt.date}T00:00:00`)); setTime(appt.time); setType(appt.type); setProvider(appt.provider); setStatus(appt.status); - }, [appt]); + } useEffect(() => { if (!open) return; diff --git a/frontend/components/appointments/calendar-dialog.tsx b/frontend/components/appointments/calendar-dialog.tsx index 4b93946..744e87c 100644 --- a/frontend/components/appointments/calendar-dialog.tsx +++ b/frontend/components/appointments/calendar-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronLeft, ChevronRight } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -72,13 +72,22 @@ export function CalendarDialog({ }); const [selectedKey, setSelectedKey] = useState(startKey); - // When opened via a deep-link date, jump the view to that month/day. - useEffect(() => { - if (!open || !initialDate || !/^\d{4}-\d{2}-\d{2}$/.test(initialDate)) return; - const d = parseKey(initialDate); - setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); - setSelectedKey(initialDate); - }, [open, initialDate]); + // When opened via a deep-link date, jump the view to that month/day. Adjusted + // during render rather than in an effect, so the calendar never paints the + // current month before jumping to the linked one. + const jumpTo = + open && initialDate && /^\d{4}-\d{2}-\d{2}$/.test(initialDate) + ? initialDate + : null; + const [prevJumpTo, setPrevJumpTo] = useState(null); + if (prevJumpTo !== jumpTo) { + setPrevJumpTo(jumpTo); + if (jumpTo) { + const d = parseKey(jumpTo); + setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + setSelectedKey(jumpTo); + } + } const byDate = useMemo(() => { const map = new Map(); diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index b74242c..ada6887 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -9,6 +9,7 @@ import { useEffect, useRef, useState, + useSyncExternalStore, } from "react"; import { useTranslation } from "react-i18next"; @@ -78,17 +79,23 @@ export function ChatInput({ const [addKey, setAddKey] = useState(0); const fileInputRef = useRef(null); - // Voice dictation (Web Speech API). Detected client-side so SSR markup and the - // first client render agree (button starts disabled, enabled by the effect). - const [speechSupported, setSpeechSupported] = useState(false); + // Voice dictation (Web Speech API). Read through useSyncExternalStore so SSR + // markup and the first client render agree: the server snapshot is `false` + // (no Speech API to detect), and the client re-reads on hydration. Support + // never changes for the life of the page, so the subscription is a no-op. + const speechSupported = useSyncExternalStore( + () => () => {}, + () => getSpeechRecognition() !== null, + () => false, + ); const [isListening, setIsListening] = useState(false); const recognitionRef = useRef(null); // The textarea contents when dictation started; transcript is appended to it. const dictationBaseRef = useRef(""); useEffect(() => { - setSpeechSupported(getSpeechRecognition() !== null); - return () => recognitionRef.current?.stop(); + const recognition = recognitionRef; + return () => recognition.current?.stop(); }, []); const toggleDictation = useCallback(() => { diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index e444b1f..a47e065 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -139,8 +139,14 @@ export function ChatPanel() { // Persisted conversation: a client-owned thread id (a fresh one per new chat), // saved to the server after each exchange so history survives reloads. const [threadId, setThreadId] = useState(() => nanoid()); + // Mirrored into a ref for the transport's fetch closure, which is memoized and + // must not be rebuilt per thread. Written in an effect rather than during + // render: a render can be thrown away or replayed, and mutating a ref there + // makes it observable — the write has to happen once the render is committed. const threadIdRef = useRef(threadId); - threadIdRef.current = threadId; + useEffect(() => { + threadIdRef.current = threadId; + }, [threadId]); // Skip the auto-save that would otherwise fire right after loading a thread // (which would needlessly bump it to the top of the history). const justLoadedRef = useRef(false); @@ -214,9 +220,11 @@ export function ChatPanel() { // stays visible until acknowledged and isn't duplicated. Reset the dismissed // flag whenever a fresh error arrives. const [errorDismissed, setErrorDismissed] = useState(false); - useEffect(() => { + const [prevError, setPrevError] = useState(error); + if (prevError !== error) { + setPrevError(error); if (error) setErrorDismissed(false); - }, [error]); + } const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama"; @@ -301,9 +309,16 @@ export function ChatPanel() { ); // Drain the queue one message at a time whenever the chat returns to idle. + // + // This is the shape the rule exists to catch, but it's the shape we want: the + // trigger is the transport going idle — an external async system — and the + // dequeue has to happen in the same tick we hand the message to `send`, or a + // re-render could drain it twice. There's no render-phase equivalent, since + // `send` is a side effect. useEffect(() => { if (status !== "ready" || pendingConsent || queued.length === 0) return; const [next, ...rest] = queued; + // eslint-disable-next-line react-hooks/set-state-in-effect setQueued(rest); if (next) void send(next.text, next.files); }, [status, pendingConsent, queued, send]); @@ -338,6 +353,10 @@ export function ChatPanel() { // Open a saved thread from `/?thread=` (sidebar history); a bare `/` starts // a fresh chat. Driven by the URL so the sidebar links and "New chat" work. + // + // Stays an effect: the thread branch is an async fetch, and the fresh-chat + // branch mints an id with nanoid(), which is impure and so can't be adjusted + // during render — moving it there would just trade this for a purity error. const requestedThread = searchParams.get("thread"); useEffect(() => { if (requestedThread) { @@ -367,6 +386,7 @@ export function ChatPanel() { }; } // No ?thread → fresh chat (e.g. after "New chat"). + // eslint-disable-next-line react-hooks/set-state-in-effect setThreadId(nanoid()); setMessages([]); }, [requestedThread, setMessages]); diff --git a/frontend/components/chat/record-edit-dialog.tsx b/frontend/components/chat/record-edit-dialog.tsx index 946c9ff..2b92a61 100644 --- a/frontend/components/chat/record-edit-dialog.tsx +++ b/frontend/components/chat/record-edit-dialog.tsx @@ -6,7 +6,7 @@ // array fields (invoice line items, inventory items) edited as add/remove rows. import { Plus, X } from "lucide-react"; -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -196,10 +196,14 @@ export function RecordEditDialog({ const schema = EDIT_SCHEMAS[kind]; const [draft, setDraft] = useState(record); - // Re-seed the draft whenever a fresh record is opened for editing. - useEffect(() => { - if (open) setDraft(record); - }, [open, record]); + // Re-seed the draft whenever a fresh record is opened for editing. Adjusted + // during render so the form never paints the previous record's values. + const [prevSeed, setPrevSeed] = useState(null); + const seed = open ? record : null; + if (prevSeed !== seed) { + setPrevSeed(seed); + if (seed) setDraft(seed); + } const setField = (key: string, value: unknown) => setDraft((d) => ({ ...d, [key]: value })); diff --git a/frontend/components/graph/record-graph.tsx b/frontend/components/graph/record-graph.tsx index 98335f3..7f9407c 100644 --- a/frontend/components/graph/record-graph.tsx +++ b/frontend/components/graph/record-graph.tsx @@ -75,7 +75,7 @@ const dotClass: Record = { // A round node with the label below it and hidden connection handles so edges // meet the dot's centre cleanly. Memoized; it re-renders only when the hovered // id changes (via context), never via React Flow re-measuring. -const RecordNode = memo(function RecordNode({ id, data }: NodeProps) { +const RecordNode = memo(function RecordNode({ data }: NodeProps) { const { label, sub, kind, family } = data as RecordNodeData; const hovered = useContext(HoverContext); const focus: "active" | "dim" | null = diff --git a/frontend/components/invoices/invoice-detail-sheet.tsx b/frontend/components/invoices/invoice-detail-sheet.tsx index 44d5cf6..eb65e69 100644 --- a/frontend/components/invoices/invoice-detail-sheet.tsx +++ b/frontend/components/invoices/invoice-detail-sheet.tsx @@ -7,7 +7,7 @@ import { Split, Trash2, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { AiBadge } from "@/components/ai-badge"; @@ -67,9 +67,14 @@ export function InvoiceDetailSheet({ const [count, setCount] = useState(3); const [busy, setBusy] = useState(false); - useEffect(() => { + // Collapse the "show more" list back when a different invoice is shown. + // Adjusted during render so the sheet never paints the previous invoice's + // expanded state. + const [prevInvoiceId, setPrevInvoiceId] = useState(invoice?.id); + if (prevInvoiceId !== invoice?.id) { + setPrevInvoiceId(invoice?.id); setCount(3); - }, [invoice?.id]); + } if (!invoice) { return ( diff --git a/frontend/components/invoices/invoice-form-dialog.tsx b/frontend/components/invoices/invoice-form-dialog.tsx index d8b1a87..f784a51 100644 --- a/frontend/components/invoices/invoice-form-dialog.tsx +++ b/frontend/components/invoices/invoice-form-dialog.tsx @@ -172,10 +172,14 @@ export function InvoiceFormDialog({ onOpenChange(next); }; - // Seed the form when opening. - useEffect(() => { - if (!open) return; - if (mode === "edit" && invoice) { + // Seed the form when opening. Adjusted during render rather than in an + // effect, so the dialog never paints one frame of the previous invoice. + // `false` means closed, so reopening on the same invoice re-seeds. + const [prevSeed, setPrevSeed] = useState(false); + const seed = open ? (invoice ?? null) : false; + if (prevSeed !== seed) { + setPrevSeed(seed); + if (open && mode === "edit" && invoice) { setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`)); // Existing invoices legitimately carry past issue dates. setAllowBackdate(true); @@ -186,7 +190,7 @@ export function InvoiceFormDialog({ setLineItems( invoice.lineItems.length ? invoice.lineItems : [emptyLine()], ); - } else { + } else if (open) { setSelected(null); setIssuedAt(new Date()); setAllowBackdate(false); @@ -196,7 +200,7 @@ export function InvoiceFormDialog({ setNotes(""); setLineItems([emptyLine()]); } - }, [open, mode, invoice]); + } // Load patients lazily for the create combobox. useEffect(() => { diff --git a/frontend/components/meetings/meeting-room.tsx b/frontend/components/meetings/meeting-room.tsx index fe15f40..1c6ee81 100644 --- a/frontend/components/meetings/meeting-room.tsx +++ b/frontend/components/meetings/meeting-room.tsx @@ -76,7 +76,6 @@ function VideoTile({ speaking ? "border-success" : "border-transparent", )} > - {/* eslint-disable-next-line jsx-a11y/media-has-caption */}