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:
2026-09-17 03:53:40 +02:00
parent 4bfba4aa86
commit f92cd9017f
8 changed files with 220 additions and 30 deletions
@@ -63,6 +63,7 @@ function mount(me: Partial<Me>) {
<DiagnosticsSettings />
</QueryClientProvider>,
);
return qc;
}
const levelButton = (name: string) =>
@@ -113,6 +114,58 @@ describe("DiagnosticsSettings", () => {
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
});
it("the focus goes back to the level picker when the box it was in goes away", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const cancel = screen.getByRole("button", { name: "settings.diagnostics.consent.cancel" });
cancel.focus();
fireEvent.click(cancel);
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
const confirm = screen.getByTestId("diagnostics-consent-confirm");
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.TRACE"));
const revert = screen.getByRole("button", { name: "settings.diagnostics.revert" });
revert.focus();
fireEvent.click(revert);
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(document.activeElement).toBe(levelButton("settings.diagnostics.levels.INFO"));
});
it("the ticking status line is not a live region", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
const status = screen.getByTestId("diagnostics-status");
expect(status.closest('[role="status"], [aria-live]')).toBeNull();
});
it("a `me` fetch that left before the change cannot put the old level back", async () => {
const qc = mount({});
let answerStale: (me: unknown) => void = () => {};
const stale = qc
.fetchQuery({
queryKey: qk.me(),
queryFn: () => new Promise((resolve) => (answerStale = resolve)),
})
.catch(() => null);
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
answerStale({
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 },
});
await stale;
expect(qc.getQueryData<Me>(qk.me())?.diagnostics.level).toBe("TRACE");
expect(screen.getByTestId("diagnostics-status")).toBeTruthy();
});
it("a lapsed level shows as the base level", () => {
const until = new Date(Date.now() - 60_000).toISOString();
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
+43 -16
View File
@@ -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">
@@ -56,6 +56,20 @@ describe("MyLogViewer", () => {
expect(rows()[0]).toContain("app.uncaught");
});
it("a row keeps its place and the focus as new events arrive above it", async () => {
render(<MyLogViewer />);
const row = within(screen.getByTestId("my-log-lines")).getAllByRole("button")[0]!;
row.focus();
fireEvent.click(row); // at Részletes this click is itself a new event in real use
await act(async () => {
log.debug("ui.click", "a newer event");
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(row.isConnected).toBe(true);
expect(document.activeElement).toBe(row);
expect(row.getAttribute("aria-expanded")).toBe("true");
});
it("filters by level group and by text, and copies only what is shown", async () => {
render(<MyLogViewer />);
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.warning/ }));
+17 -2
View File
@@ -51,6 +51,21 @@ function useLogEvents(): LogEvent[] {
return list;
}
// A row's React key: the event OBJECT's own number, handed out the first time it is drawn. The ring
// keeps the same objects, so the key stays put as the list grows at the top — an index key shifted
// every row on every new event, remounting all of them and dropping the focus off the row button
// (at Részletes a click on a row logs a line itself, so it happened on every click).
const rowIds = new WeakMap<LogEvent, number>();
let nextRowId = 0;
const rowKey = (event: LogEvent) => {
let id = rowIds.get(event);
if (id === undefined) {
id = nextRowId++;
rowIds.set(event, id);
}
return id;
};
const matches = (event: LogEvent, needle: string) =>
!needle ||
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
@@ -151,11 +166,11 @@ export default function MyLogViewer() {
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p>
) : (
shown.slice(0, ROWS_SHOWN).map((e, i) => {
shown.slice(0, ROWS_SHOWN).map((e) => {
const tag = LEVEL_TAG[e.level];
const isOpen = open === e;
return (
<div key={`${e.ts}-${i}`} className="border-b border-border/50 last:border-b-0">
<div key={rowKey(e)} className="border-b border-border/50 last:border-b-0">
<button
type="button"
aria-expanded={isOpen}