frontend: clear the 99 lint errors

`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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-15 20:54:37 +03:00
parent 7c92c03eed
commit e8f3ed9ffe
28 changed files with 325 additions and 136 deletions
@@ -8,7 +8,7 @@ import {
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -115,12 +115,19 @@ export function EmployeeDetailDialog({
const [confirmPw, setConfirmPw] = useState("");
const [savingPw, setSavingPw] = useState(false);
useEffect(() => {
// Re-seed when a different member (or their role/specialty) is shown, and
// always clear the password fields so they can't carry across. Adjusted
// during render rather than in an effect, so the dialog never paints another
// member's values — and a typed password never survives a switch.
const seed = `${member?.id ?? ""}|${member?.role ?? ""}|${member?.specialty ?? ""}`;
const [prevSeed, setPrevSeed] = useState(seed);
if (prevSeed !== seed) {
setPrevSeed(seed);
setRole(member?.role ?? "");
setSpecialty(member?.specialty ?? "");
setNewPw("");
setConfirmPw("");
}, [member?.id, member?.role, member?.specialty]);
}
const summary = rolePermissionSummary(member?.role);
const secondary = member?.username ? `@${member.username}` : member?.email;
@@ -1,7 +1,7 @@
"use client";
import { Info, UserPlus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AddStaffDialog } from "@/components/settings/add-staff-dialog";
@@ -64,36 +64,47 @@ export function CareTeamPanel({
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null);
const [removing, setRemoving] = useState(false);
const load = useCallback(async () => {
try {
const data = await apiFetch<StaffMember[]>("/api/staff");
setMembers(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// The effect owns the fetch; `reload()` just bumps a key to re-run it. That
// keeps every setState inside a promise callback rather than running
// synchronously when the effect fires, and it drops the exhaustive-deps
// suppression the old useCallback needed.
const [reloadKey, setReloadKey] = useState(0);
const reload = () => setReloadKey((k) => k + 1);
useEffect(() => {
void load();
}, [load]);
let active = true;
apiFetch<StaffMember[]>("/api/staff")
.then((data) => {
if (!active) return;
setMembers(data);
setError(null);
})
.catch((err: unknown) => {
if (!active) return;
setError(
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
// `t` is intentionally omitted: re-fetching the staff list on a language
// change would be pointless, and the message is only read on failure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey]);
// Deep-link: open a specific member (e.g. from a password-reset system card).
const appliedDeepLink = useRef(false);
useEffect(() => {
if (appliedDeepLink.current || !initialMemberId || members.length === 0)
return;
// Resolved during render once the list arrives, so the member's dialog opens
// on the same paint the list does.
const [appliedDeepLink, setAppliedDeepLink] = useState(false);
if (!appliedDeepLink && initialMemberId && members.length > 0) {
setAppliedDeepLink(true);
const target = members.find((m) => m.userId === initialMemberId);
if (target) {
setSelected(target);
appliedDeepLink.current = true;
}
}, [initialMemberId, members]);
if (target) setSelected(target);
}
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
const canManage = myRole === "owner" || myRole === "admin";
@@ -119,7 +130,7 @@ export function CareTeamPanel({
}),
);
setPendingRemove(null);
void load();
reload();
};
return (
@@ -211,7 +222,7 @@ export function CareTeamPanel({
{canManage && (
<AddStaffDialog
onCreated={() => void load()}
onCreated={reload}
onOpenChange={setAdding}
open={adding}
/>
@@ -225,7 +236,7 @@ export function CareTeamPanel({
selected?.role !== "owner"
}
member={selected}
onChanged={() => void load()}
onChanged={reload}
onOpenChange={(o) => !o && setSelected(null)}
onRemove={(m) => {
setSelected(null);
@@ -39,12 +39,18 @@ export function PatientPortalSection() {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
// Drop the previous clinic's QR as soon as the slug changes, rather than
// leaving it on screen until the new one arrives. Adjusted during render, so
// switching clinics never shows the old clinic's pairing code.
const [prevSlug, setPrevSlug] = useState(slug);
if (prevSlug !== slug) {
setPrevSlug(slug);
setQrUri("");
}
// Fetch the relay-based pairing descriptor for the QR (non-secret).
useEffect(() => {
if (!slug) {
setQrUri("");
return;
}
if (!slug) return;
let active = true;
getPortalLink(slug)
.then((link) => {
@@ -113,13 +113,17 @@ export function ProfilePanel() {
};
}, []);
// Seed the display name from the session once it loads.
useEffect(() => {
// Seed the display name from the session once it loads. Adjusted during
// render so the field isn't briefly empty after the session resolves. Still
// `prev ||`, so it never clobbers something the clinician has typed.
const [prevUserName, setPrevUserName] = useState(user?.name);
if (prevUserName !== user?.name) {
setPrevUserName(user?.name);
if (user?.name) {
setName((prev) => prev || user.name);
setBaselineName((prev) => prev || user.name);
}
}, [user?.name]);
}
const setPref = (key: string, value: boolean | string) =>
setPrefs((prev) => ({ ...prev, [key]: value }));