fix: the diagnostics tab keeps its focus, its level and its consent
Deep-review round 1 of E22. - After a 429 the stream sent one POST per new event while a full batch waited; it now stays quiet for 15 s. Past the queue cap the oldest events go, as the comment always said, a refused batch included. - A batch refused after the level was lowered came back into the queue of a later consent in the same tab; each consent is its own session. - A `me` fetch that left before a level or preference change could put the old value back for up to a minute. `updateMeCache` writes the cache and cancels that fetch; usePref goes through it too. - The consent box's buttons and "revert now" took the focus with them to <body>; it returns to the level picker. Watched until the button is gone — in Chrome the saved level arrives in two renders, and a one-shot check spent itself on the first (jsdom never showed it). - The countdown line is no longer a live region re-read every minute. - "Saját napló" rows were keyed by index on a newest-first list, so every new event remounted every row and dropped the focus off a row button.
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DiagnosticsLevel, type Me } from "../lib/api";
|
||||
import { api, type DiagnosticsLevel } from "../lib/api";
|
||||
import { levelInForce } from "../lib/diagnostics";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { updateMeCache, useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
@@ -42,17 +41,42 @@ export default function DiagnosticsSettings() {
|
||||
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
|
||||
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
|
||||
|
||||
// The consent box and the status line each go away with the button that closed them — the one
|
||||
// holding the focus. Put it back on the level picker (its chosen option) rather than let it fall
|
||||
// to <body>, where the next Tab starts over from the top of the page. Only when it did fall: a
|
||||
// user who moved on while the request was out keeps their place.
|
||||
//
|
||||
// Watched until that button is actually GONE, not for one render: a saved level arrives as two
|
||||
// updates (the `me` cache, then the closed box), and the first render still has the button in the
|
||||
// page — measured in Chrome, where a one-shot check spent itself there and the focus then fell.
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const closedFrom = useRef<Element | null>(null);
|
||||
useEffect(() => {
|
||||
const from = closedFrom.current;
|
||||
if (!from || from.isConnected) return;
|
||||
closedFrom.current = null;
|
||||
if (document.activeElement && document.activeElement !== document.body) return;
|
||||
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
|
||||
});
|
||||
const watchFocus = () => {
|
||||
closedFrom.current = document.activeElement;
|
||||
};
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: api.setDiagnosticsLevel,
|
||||
meta: { failTitle: "settings.diagnostics.failed" },
|
||||
onSuccess: (result) => {
|
||||
qc.setQueryData<Me | null>(qk.me(), (prev) =>
|
||||
prev ? { ...prev, diagnostics: result.diagnostics } : prev,
|
||||
);
|
||||
watchFocus();
|
||||
updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics }));
|
||||
setPending(null);
|
||||
},
|
||||
});
|
||||
|
||||
const cancel = () => {
|
||||
watchFocus();
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
const choose = (level: DiagnosticsLevel) => {
|
||||
if (level === "INFO") {
|
||||
setPending(null);
|
||||
@@ -74,13 +98,15 @@ export default function DiagnosticsSettings() {
|
||||
label={t("settings.diagnostics.level")}
|
||||
hint={t("settings.diagnostics.levelHint")}
|
||||
>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("settings.diagnostics.level")}
|
||||
value={pending ?? current}
|
||||
onChange={choose}
|
||||
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
|
||||
/>
|
||||
<div ref={pickerRef}>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("settings.diagnostics.level")}
|
||||
value={pending ?? current}
|
||||
onChange={choose}
|
||||
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
@@ -111,7 +137,7 @@ export default function DiagnosticsSettings() {
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPending(null)}>
|
||||
<Button variant="ghost" size="sm" onClick={cancel}>
|
||||
{t("settings.diagnostics.consent.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -127,9 +153,10 @@ export default function DiagnosticsSettings() {
|
||||
)}
|
||||
|
||||
{current !== "INFO" && !pending && remaining && (
|
||||
// Not a live region: the countdown changes every minute, and a screen reader would read
|
||||
// the whole line out again each time for as long as the page is open.
|
||||
<div
|
||||
data-testid="diagnostics-status"
|
||||
role="status"
|
||||
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user