Files
ihasmail/web/src/ui/__tests__/dialog-focus.test.tsx
T
jcoffey-dev 0fdcbbc778 Fix two things live testing on 0.15.5 turned up
**About said "not detected".** Generation was only worked out from the reply to
a registry method, which we never send to a server that does not advertise
urn:stalwart:jmap — every 0.16 build does, and nothing older knows the
capability at all, so its absence is already the answer. Say so, instead of
shrugging. A session with no capabilities at all stays unknown, which is a
different thing from old.

**The caret jumped out of the OTP field after one digit.** Dialog's autofocus
effect listed onClose in its dependencies, and every caller passes an inline
arrow, so each keystroke in a dialog holding state tore the effect down, set it
up again, and refocused the first field — which in the disable-2FA dialog is
the password. Keep the handler in a ref so the effect depends only on `open`.
This was a bug in the shared dialog rather than in one screen; every dialog
with more than one field had it.

The test for it fails against the old dependency array, not just passes
against the new one.
2026-08-24 09:04:16 -07:00

83 lines
2.7 KiB
TypeScript

import { act, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Dialog } from "../dialog";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* Dialogs are almost always given an inline arrow for onClose, so its identity
* changes on every render of the parent. While that was in the effect's
* dependencies, any dialog holding state tore the effect down and set it up
* again on each keystroke — and its autofocus dragged the caret back to the
* first field. Typing a digit into the second field jumped you to the first.
*/
/** A dialog with two fields, whose parent re-renders as either is typed in. */
function TwoFieldDialog() {
const [first, setFirst] = useState("");
const [second, setSecond] = useState("");
return (
<Dialog open onClose={() => undefined} title="Two fields">
<input id="first" value={first} onChange={(e) => setFirst(e.target.value)} />
<input id="second" value={second} onChange={(e) => setSecond(e.target.value)} />
</Dialog>
);
}
describe("Dialog focus handling", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const type = (el: HTMLInputElement, value: string) => {
act(() => {
el.focus();
// What React's onChange sees when a character is typed.
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
});
};
it("autofocuses the first field when it opens", async () => {
act(() => root.render(<TwoFieldDialog />));
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
expect(document.activeElement?.id).toBe("first");
});
it("leaves the caret alone while a later field is typed in", async () => {
act(() => root.render(<TwoFieldDialog />));
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
const second = document.getElementById("second") as HTMLInputElement;
type(second, "1");
// The old effect re-ran here and pulled focus back to the first field.
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
expect(document.activeElement?.id).toBe("second");
type(second, "12");
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
expect(document.activeElement?.id).toBe("second");
expect(second.value).toBe("12");
});
});