UX-8 layerStack: add an integration test wired through the real useLayer hook (popover document-listener + stamp) and a PlayerModal-shaped window listener, driven by a real dispatched keydown. The popover close is wrapped in flushSync so its layer is popped synchronously between the two listeners -- the production mid-dispatch race -- making the escapeHandled stamp the only thing that keeps the player from also closing. Mutation-checked: removing the guard fails exactly this case. SavedViews: cover feedCanSeedDefault's "yield to a bookmarked filter URL" branch (a filter-bearing search string -> no seed) and the per-source seededRef guard against the 60s refetch (a changed-list refetch re-runs the seed effect; the guard blocks re-seed). The refetch test waits for the changed list to reach the component so the effect has its chance to wrongly re-fire. Both mutation-checked. Frontend gate green: 464 vitest (+4). Backend untouched.
94 lines
4.2 KiB
TypeScript
94 lines
4.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Integration guard for the UX-8 single-Escape fix — the sibling of layerStack.dom.test.ts, but wired
|
|
// through the REAL `useLayer` hook and a REAL dispatched keydown instead of hand-rolled listeners. It
|
|
// mounts the two production listener shapes as React components:
|
|
// - the POPOVER exactly as AnchoredPopover uses it: `useLayer(open, onEscape)` installs the real
|
|
// `document` keydown listener that stamps the event and closes on topmost;
|
|
// - the PLAYER exactly as PlayerModal reads it: a `window` keydown listener that bails on
|
|
// `escapeHandled(e)` first, then on `anyAbove()`.
|
|
// The load-bearing subtlety the primitives test can only fake: the popover's close is wrapped in
|
|
// `flushSync`, so React tears the popover down (and `useLayer` cleanup pops its layer) SYNCHRONOUSLY
|
|
// between the `document` and `window` listeners — the real mid-dispatch flush. That makes the stack read
|
|
// "nothing above the player" by the time the player's listener runs, so ONLY the event stamp can stop
|
|
// the player from also closing. If the stamp regressed, test 1 would see the player close on the first
|
|
// Escape. floating-ui isn't involved here (we drive `useLayer` directly), so no observer stubs needed.
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { useEffect, useState } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { render, act, cleanup } from "@testing-library/react";
|
|
import { useLayer, escapeHandled } from "./layerStack";
|
|
|
|
afterEach(cleanup);
|
|
|
|
// The player: registers a layer (no onEscape — it owns a richer handler) and a `window` listener that
|
|
// mirrors PlayerModal's guard order (consumed-stamp first, then anyAbove).
|
|
function Player({ onClose }: { onClose: () => void }) {
|
|
const { anyAbove } = useLayer(true);
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key !== "Escape") return;
|
|
if (escapeHandled(e)) return; // the UX-8 fix
|
|
if (anyAbove()) return; // the old, race-prone guard
|
|
onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [anyAbove, onClose]);
|
|
return <div data-testid="player" />;
|
|
}
|
|
|
|
// The popover: the real AnchoredPopover usage. flushSync makes the unmount (and its layer removal) run
|
|
// synchronously inside the `document` listener — the production mid-dispatch flush the fix must survive.
|
|
function Popover({ onClosed }: { onClosed: () => void }) {
|
|
const [open, setOpen] = useState(true);
|
|
useLayer(open, () => {
|
|
flushSync(() => setOpen(false));
|
|
onClosed();
|
|
});
|
|
return open ? <div data-testid="popover" /> : null;
|
|
}
|
|
|
|
function dispatchEscape(): KeyboardEvent {
|
|
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
|
act(() => {
|
|
document.dispatchEvent(e);
|
|
});
|
|
return e;
|
|
}
|
|
|
|
describe("layerStack Escape coordination — live useLayer + real dispatch (UX-8)", () => {
|
|
it("one Escape closes only the topmost popover; a second closes the player", () => {
|
|
const playerClose = vi.fn();
|
|
const popoverClosed = vi.fn();
|
|
const { queryByTestId } = render(
|
|
<>
|
|
<Player onClose={playerClose} />
|
|
<Popover onClosed={popoverClosed} />
|
|
</>,
|
|
);
|
|
// Player registers first, popover second → popover is topmost.
|
|
expect(queryByTestId("popover")).not.toBeNull();
|
|
|
|
// First Escape: popover acts (stamps + flushSync-closes, removing its layer synchronously), player
|
|
// defers on the stamp even though the stack now shows nothing above it.
|
|
const first = dispatchEscape();
|
|
expect(escapeHandled(first)).toBe(true);
|
|
expect(popoverClosed).toHaveBeenCalledOnce();
|
|
expect(queryByTestId("popover")).toBeNull();
|
|
expect(playerClose).not.toHaveBeenCalled();
|
|
|
|
// Second Escape: nothing above the player, unstamped → the player closes.
|
|
const second = dispatchEscape();
|
|
expect(escapeHandled(second)).toBe(false);
|
|
expect(playerClose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("with no layer above, a lone player closes on the first Escape", () => {
|
|
const playerClose = vi.fn();
|
|
render(<Player onClose={playerClose} />);
|
|
dispatchEscape();
|
|
expect(playerClose).toHaveBeenCalledOnce();
|
|
});
|
|
});
|