Files
siftlode/frontend/src/lib/layerStack.dom.test.ts
T
peter 90fc86d437 fix(diagnostics): deep-review round 4 — a claim only its owner may write, and a player that keeps the Tab
Round 4 was targeted at the surfaces rounds 2 and 3 changed. 29 findings raised,
26 survived deduplication, 12 survived three adversarial readings each.

`set_delivery` renewed the claim but never checked it was still ours, while its
sibling `release_send` always did. `expire_on_commit` is off, so the token it
re-stamped came from the caller's own memory, not the row: a send that outlived
its 600 s lease came back, overwrote the take-over send's committed links with a
full-column write, and put its own dead token back. It now takes the token, reads
the row's claim with a targeted scalar (not a refresh, which would discard the
`issue_url` assigned one line earlier) and raises `SendClaimLost` rather than
writing. An issue created by a send that had already lost its claim is logged with
its number — that record is the only one that will exist.

The tracker's settings are read above the pre-call checkpoint instead of inside
`_client`. Reading them there was a SELECT one line after the commit that was
meant to end the transaction, and held it — idle, snapshot pinned — across the
repository check and the create. The comment and the module docstring said
otherwise; measuring it in pg_stat_activity is what settled it. Two more comments
were saying things the code had stopped doing: the `**state` spread does not carry
the claim (`set_delivery` strips and re-stamps it), and react-query does not
debounce the preview — the editor being unmounted is what paces it.

The composer stopped stranding the focus on `<body>`: a send that creates the
issue and then fails on an attachment unmounts the button that was just pressed,
and only the success path handed the focus on. Export no longer saves into a
refusal it does not need — the zip is the way out of a stuck send, and its
pre-save was the one step that could not survive one. The delivery banner
announces through a role instead of an `aria-live` created with its own message.

`historyState` returns a union, so `chipTone` matching on the same keys is checked
by tsc rather than by hope.

PlexPlayer was the only full-screen overlay in the tree with no focus containment
at all — Tab walked out of an opaque player into a page nobody can see. It now
carries the shared trap. Closing that hole exposed the one underneath it:
`useDismiss` registered as a layer that traps the Tab while installing no trap,
so the player stood its own down for its own menus. Measured in a real browser:
30 Tabs with the settings panel open, 9 escapes into the nav rail, and 0 after.
Escape ordering is unchanged — the panel first, then the player.

Three tests that did not hold their line: the advisory lock's call site (deleting
it left the suite green), the trail assertion whose `all()` was vacuous over an
empty filter, and a `layerStack` assertion the stand-in dialog already forced.
All eleven executable fixes mutation-checked red for the right reason; the two
comment corrections have nothing to pin.
2026-09-20 04:30:42 +02:00

119 lines
4.9 KiB
TypeScript

// @vitest-environment jsdom
//
// Guards the UX-8 fix: one Escape must close only the TOPMOST layer, never both an open popover AND
// the player beneath it. The bug was that the popover's Escape listener (on `document`) fires before
// the player's (on `window`), and closing the popover flushes its layer off the stack synchronously —
// so by the time the player's `anyLayerAbove()` lookup runs, the stack already reads "nothing above"
// and the player closes too. The fix stamps the shared KeyboardEvent when a layer acts; the player
// reads the stamp instead of the (now-mutated) stack. This test reproduces that exact ordering with
// the stack primitives — the layer removes itself between the two listeners, mimicking the flush.
import { describe, it, expect, vi, afterEach } from "vitest";
import {
pushLayer,
removeLayer,
anyLayerAbove,
anyBlockingLayer,
anyBlockingLayerAbove,
isTopmost,
markEscapeHandled,
escapeHandled,
} from "./layerStack";
// Keep the module-level stack clean between cases (ids never repeat, but membership must not leak).
const opened: number[] = [];
function open(): number {
const id = pushLayer();
opened.push(id);
return id;
}
afterEach(() => {
while (opened.length) removeLayer(opened.pop()!);
});
describe("layerStack Escape coordination (UX-8)", () => {
it("a player defers even when the acting layer is popped mid-dispatch", () => {
const playerId = open();
const popoverId = open();
const playerClose = vi.fn();
// Popover: the `document` listener from useLayer — topmost acts, stamps the event, and (the
// regression) its close synchronously removes the layer before the window listener runs.
const onDocumentKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopmost(popoverId)) {
markEscapeHandled(e);
removeLayer(popoverId); // the synchronous React flush the real close triggers
}
};
// Player: the `window` listener — fires AFTER the document one, reading an already-mutated stack.
const onWindowKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (escapeHandled(e)) return; // the fix
if (anyLayerAbove(playerId)) return; // the old, now-insufficient guard
playerClose();
};
const e = new KeyboardEvent("keydown", { key: "Escape" });
onDocumentKey(e);
onWindowKey(e);
// The stamp holds even though the stack no longer shows a layer above the player...
expect(escapeHandled(e)).toBe(true);
expect(anyLayerAbove(playerId)).toBe(false); // ...which is exactly why anyLayerAbove alone failed
expect(playerClose).not.toHaveBeenCalled();
});
it("a plain Escape with no layer above still closes the player", () => {
const playerId = open();
const playerClose = vi.fn();
const e = new KeyboardEvent("keydown", { key: "Escape" });
if (!escapeHandled(e) && !anyLayerAbove(playerId)) playerClose();
expect(playerClose).toHaveBeenCalledOnce();
});
it("stamps are per-event: a fresh Escape is not treated as already-handled", () => {
const first = new KeyboardEvent("keydown", { key: "Escape" });
markEscapeHandled(first);
const second = new KeyboardEvent("keydown", { key: "Escape" });
expect(escapeHandled(second)).toBe(false);
});
});
// A layer answers TWO questions, and round 3 found they are not the same one: "do the page's own
// keys stay quiet?" and "do I take the Tab trap from the dialog below?". The recorder's step prompt
// is the case that separates them — a field inside whatever dialog the pill is docked in, so it
// owns the keyboard but its two controls are no trap for that dialog's many.
describe("owning the keyboard and owning the Tab trap", () => {
it("an inline prompt silences the page and still leaves the dialog its trap", () => {
const dialog = open();
const prompt = pushLayer({ trapsTab: false });
opened.push(prompt);
expect(anyBlockingLayerAbove(dialog)).toBe(false); // the dialog keeps trapping Tab...
// ...and the page's own keys are quiet by the PROMPT's doing, not the stand-in dialog's: the
// dialog is non-passive too, so asserting this with it still on the stack asserted nothing.
removeLayer(dialog);
expect(anyBlockingLayer()).toBe(true); // `j`, `g h` and the rest stay off the page behind
});
it("a real dialog above does take the trap from the one below", () => {
const dialog = open();
const above = open();
expect(anyBlockingLayerAbove(dialog)).toBe(true);
expect(anyBlockingLayerAbove(above)).toBe(false);
});
it("a passive layer takes neither: a hover card must silence nothing", () => {
const card = pushLayer({ passive: true });
opened.push(card);
expect(anyBlockingLayer()).toBe(false); // `j` still moves the list under a resting pointer
const dialog = open();
const over = pushLayer({ passive: true });
opened.push(over);
expect(anyBlockingLayerAbove(dialog)).toBe(false); // ...and it is no trap either
});
});