From f92cd9017fc7bcdf313df712f0f2e603f5057cf7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 17 Sep 2026 03:53:40 +0200 Subject: [PATCH] fix: the diagnostics tab keeps its focus, its level and its consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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. --- .../DiagnosticsSettings.dom.test.tsx | 53 +++++++++++++++++ .../src/components/DiagnosticsSettings.tsx | 59 ++++++++++++++----- .../src/components/MyLogViewer.dom.test.tsx | 14 +++++ frontend/src/components/MyLogViewer.tsx | 19 +++++- frontend/src/lib/diagnostics.dom.test.ts | 58 +++++++++++++++++- frontend/src/lib/diagnostics.ts | 30 ++++++++-- frontend/src/lib/useMe.ts | 12 +++- frontend/src/lib/usePref.ts | 5 +- 8 files changed, 220 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/DiagnosticsSettings.dom.test.tsx b/frontend/src/components/DiagnosticsSettings.dom.test.tsx index 48e5b165..f4531b10 100644 --- a/frontend/src/components/DiagnosticsSettings.dom.test.tsx +++ b/frontend/src/components/DiagnosticsSettings.dom.test.tsx @@ -63,6 +63,7 @@ function mount(me: Partial) { , ); + 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(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 } }); diff --git a/frontend/src/components/DiagnosticsSettings.tsx b/frontend/src/components/DiagnosticsSettings.tsx index 8b0380fd..c1a67440 100644 --- a/frontend/src/components/DiagnosticsSettings.tsx +++ b/frontend/src/components/DiagnosticsSettings.tsx @@ -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(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 , 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(null); + const closedFrom = useRef(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('[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(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")} > - ({ value: level, label: levelName(level) }))} - /> +
+ ({ value: level, label: levelName(level) }))} + /> +
)} @@ -111,7 +137,7 @@ export default function DiagnosticsSettings() {
-