**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.
83 lines
2.7 KiB
TypeScript
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");
|
|
});
|
|
});
|