fix(ui): a dialog keeps the focus it was given, and hands it back where it took it from
Round 5 extracted `ui/dialogFocus` so no shell would hand-roll the focus contract, then introduced four ways to lose the focus anyway — all four measured in jsdom against the real components. **Ownership was narrower than the trap.** `ui/Modal` traps Tab over the whole dialog on purpose, so a toast's Undo or ✕ stays reachable; ownership was tracked over the panel alone. Tabbing to that ✕ therefore read as "the user left the dialog", and since removing a toast fires no `focusin` to re-arm, closing afterwards skipped the restore and left the focus on <body>. Before this hook existed the restore was unconditional, so it was a regression. **A dialog stacked above us is still ours.** A nested `ui/Modal` portals into its own host, so it is outside the outer dialog's root however wide that scope is drawn — widening alone could not fix it. Both closing in one commit left the focus on <body>: the outer skipped its restore, and the child's own target (a control inside the outer panel) was already detached, because passive cleanups run parent-first. Ownership now means "inside my dialog, or inside a modal layer above it", which is what the rule always intended. **The wizard disagreed with `ui/Modal` on its first line.** `trapTab(e, panel)`, with neither the dialog-root scope (so its own toast host was outside the cycle — the connection notice never auto-dismisses, and its ✕ was unreachable from the keyboard) nor the `container` argument the JSDoc exists for: `panel.contains( panel)` is true, so focus sitting ON the tabIndex=-1 panel matched no branch and a Shift+Tab walked into the app behind the scrim. Both call sites now pass the same three arguments. **The toast handover was a render-phase read-modify-write.** A `useMemo` read a module global that the next line overwrote; React re-invokes a component body and every `useMemo` factory in it on a StrictMode double render, and the LAST invocation commits — so the second pass re-read what the first had just written, the takeover host classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5 replaced. Snapshotted through a ref keyed on `isTop`, so a repeated invocation of one render is a no-op. And the fourth `aria-modal` shell, `Welcome`'s screenshot lightbox, is finally swept into the same contract: it declared itself a modal dialog while leaving the focus on the thumbnail behind an 80%-black scrim and letting Tab walk the landing page underneath. The headline fix of the previous commit — StrictMode's re-mount taking the focus off an `autoFocus` field — had no test at all; deleting it left all 1009 green. It has one now. 9 tests, mutation-checked against 8 mutations, each red on the test that names it.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications";
|
||||
@@ -43,6 +43,7 @@ const topHost = () => {
|
||||
|
||||
// What the host that is currently top last rendered, for the next host to inherit. See `inherited`.
|
||||
let lastTopIds: number[] = [];
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set<number>();
|
||||
|
||||
export default function Toaster({
|
||||
overlay = false,
|
||||
@@ -81,9 +82,24 @@ export default function Toaster({
|
||||
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
|
||||
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
|
||||
// host's last screen either way.
|
||||
const inherited = useMemo(() => new Set(isTop ? lastTopIds : []), [isTop]);
|
||||
// AFTER the memo above has read the outgoing host's screen — this host's own first render as top
|
||||
// would otherwise overwrite the handover with the toasts it is about to classify.
|
||||
//
|
||||
// Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
|
||||
// — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
|
||||
// the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
|
||||
// first pass had already overwritten it on the line below, so the takeover host classified its
|
||||
// OWN new toast as inherited and the dev build behaved exactly like the code this replaced
|
||||
// (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
|
||||
// recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
|
||||
const handover = useRef<{ top: boolean; ids: ReadonlySet<number> }>({
|
||||
top: false,
|
||||
ids: EMPTY_IDS,
|
||||
});
|
||||
if (handover.current.top !== isTop)
|
||||
handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
|
||||
const inherited = handover.current.ids;
|
||||
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
|
||||
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
|
||||
// same value twice is harmless; that is the point of the ref.
|
||||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user