fix(e10): review round 2 — revert what round 1 got wrong, narrow what it got too broad
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.
REVERTED, because the mechanism does not work:
- The `inert` menu item. Round 1 swapped a native `disabled` for
`aria-disabled` so a row's "why is this off" tooltip could be reached.
floating-ui's list navigation skips `aria-disabled="true"` exactly as it
skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
comment, written before this branch, already said so. The swap fixed
nothing and made the mouse worse: without native `disabled` the row took
pointer events, the full active highlight and DOM focus, then no-opped
on click. The underlying finding is logged where the prop was, unfixed
and honest: the reason has to go somewhere a disabled row is not.
- `useContextMenu` owning the focus restore. Turning floating-ui's
returnFocus off platform-wide traded one bug for two: Playlists spreads
triggerProps on a bare <tr>, so restoring to it is a spec no-op and
Escape stranded focus on <body>; and with dismissOnScroll now on, the
restore's plain focus() scrolled the row back into view, undoing the
scroll that closed the menu. Patching that needs three more guards on
one fix. The original bug — the rail's rename field dying on arrival —
is fixed at the field instead: it focuses from an effect, which is
scheduled after the microtask that was blurring it.
NARROWED, because the fix was right and the guard was not:
- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
gear and tracks menus register layers via useDismiss — so Backspace
stopped closing them and did nothing at all. Only the cheat sheet and
the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
navigation does NOT unmount (About and ReleaseNotes live outside the
page column), leaving one Back to both navigate and close them. The
entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
session and went stale across a close. `e.movementX/Y` asks the browser
directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
mounts two — so the cheat sheet rendered the chords twice under a
duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
variant's own colour class outranks under clsx, so Revoke hovered
neutral and a password-protected link looked unprotected. The colour
goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
scope, so `back` would have answered for the wrong player. Keyed on
scope + id, both literal.
And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.
The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
This commit is contained in:
@@ -25,6 +25,14 @@ function count(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
/** Occurrences of `chipLaneClass` that are actually a LANE, i.e. not the import that brings it in.
|
||||
* Counting raw occurrences gave every file one free mirror — the import — so a second, unprotected
|
||||
* lane in a file that already had a compliant one still passed, which is the exact hole this test
|
||||
* was rewritten to close (E10 review round 2, caught by mutation). */
|
||||
function laneCount(src: string): number {
|
||||
return count(src.replace(/^\s*import[\s\S]*?from\s+"[^"]*";$/gm, ""), "chipLaneClass");
|
||||
}
|
||||
|
||||
/** Every source file under src/, not just src/components/ — App.tsx and main.tsx sit above it. */
|
||||
function sources(): Array<[string, string]> {
|
||||
const walk = (u: URL, prefix: string): Array<[string, string]> =>
|
||||
@@ -58,13 +66,15 @@ describe("chip lane containment contract", () => {
|
||||
.map(([name, src]) => ({
|
||||
name,
|
||||
mirrors: count(src, MIRROR),
|
||||
lanes: count(src, "chipLaneClass"),
|
||||
lanes: laneCount(src),
|
||||
}))
|
||||
.filter((f) => f.lanes < f.mirrors);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the measurers it claims to guard (the marker has not been renamed away)", () => {
|
||||
expect(withMirror().length).toBeGreaterThanOrEqual(3);
|
||||
// The MIRROR alone, not the hook: `lib/chipOverflow.ts` names the hook in a prose comment and was
|
||||
// padding this canary, so it stayed satisfied with one real measurer fewer (round 2).
|
||||
expect(sources().filter(([, src]) => src.includes(MIRROR)).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The `global` scope's LIVE behaviour, as opposed to `matches`/`dispatch` in isolation (keymap.test.ts
|
||||
// covers those against a handler map the test supplies itself, which cannot see the real handlers).
|
||||
//
|
||||
// Every assertion here pins a fix that E10 review round 2 proved was un-pinned: reverting the guard
|
||||
// each one covers left the whole suite green, which is how a fix round quietly becomes the next
|
||||
// round's source of regressions.
|
||||
import { useEffect } from "react";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
closeKeyboardHelp,
|
||||
closePalette,
|
||||
useGlobalKeymap,
|
||||
useKeyboardHelpOpen,
|
||||
usePaletteOpen,
|
||||
} from "./keymap";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeKeyboardHelp();
|
||||
closePalette();
|
||||
});
|
||||
|
||||
/** Renders the live stores so an assertion reads what a real consumer would render. */
|
||||
function Host({ enabled = true, onState }: { enabled?: boolean; onState: (s: string) => void }) {
|
||||
useGlobalKeymap(enabled);
|
||||
const help = useKeyboardHelpOpen();
|
||||
const palette = usePaletteOpen();
|
||||
useEffect(() => {
|
||||
onState(`${help ? "help" : ""}${palette ? "palette" : ""}` || "none");
|
||||
}, [help, palette, onState]);
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = (init: KeyboardEventInit) =>
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("useGlobalKeymap", () => {
|
||||
it("opens the cheat sheet on h and the palette on Mod+K", () => {
|
||||
let state = "";
|
||||
render(<Host onState={(s) => (state = s)} />);
|
||||
key({ key: "h" });
|
||||
expect(state).toBe("help");
|
||||
key({ key: "h" });
|
||||
expect(state).toBe("none");
|
||||
key({ key: "k", ctrlKey: true });
|
||||
expect(state).toBe("palette");
|
||||
});
|
||||
|
||||
// Mod+K is written as a toggle, and the palette autofocuses its own search field — so without
|
||||
// `allowInInput` on the row the second press was eaten by the typing guard and Chrome took the
|
||||
// chord instead (round 1). The FIRST press must still be routed from a field, too.
|
||||
it("toggles the palette shut from inside a text field", () => {
|
||||
let state = "";
|
||||
render(<Host onState={(s) => (state = s)} />);
|
||||
const field = document.createElement("input");
|
||||
document.body.appendChild(field);
|
||||
field.focus();
|
||||
|
||||
act(() => {
|
||||
field.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(state).toBe("palette");
|
||||
act(() => {
|
||||
field.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(state).toBe("none");
|
||||
field.remove();
|
||||
});
|
||||
|
||||
// `h` still belongs to whatever is being typed.
|
||||
it("leaves a plain letter to the text field it was typed into", () => {
|
||||
let state = "";
|
||||
render(<Host onState={(s) => (state = s)} />);
|
||||
const field = document.createElement("input");
|
||||
document.body.appendChild(field);
|
||||
field.focus();
|
||||
act(() => {
|
||||
field.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "h", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(state).toBe("none");
|
||||
field.remove();
|
||||
});
|
||||
|
||||
// Each toggle mounts/unmounts an overlay that costs a real history entry, so ~30 of them a second
|
||||
// desyncs the overlay bookkeeping from the browser's actual stack. Holding a key is not a press.
|
||||
it("ignores auto-repeat", () => {
|
||||
let state = "";
|
||||
render(<Host onState={(s) => (state = s)} />);
|
||||
key({ key: "h", repeat: true });
|
||||
expect(state).toBe("none");
|
||||
key({ key: "k", ctrlKey: true, repeat: true });
|
||||
expect(state).toBe("none");
|
||||
});
|
||||
|
||||
// Bound above the branch that mounts the overlays, the keys were swallowed on the loading/Welcome
|
||||
// screens and the flipped store then sprang the overlay open by itself once the shell mounted.
|
||||
it("binds nothing, and clears a stale store, while disabled", () => {
|
||||
let state = "";
|
||||
const { rerender } = render(<Host onState={(s) => (state = s)} />);
|
||||
key({ key: "h" });
|
||||
expect(state).toBe("help");
|
||||
|
||||
rerender(<Host enabled={false} onState={(s) => (state = s)} />);
|
||||
expect(state).toBe("none"); // the stale `true` is cleared, not carried into the next mount
|
||||
key({ key: "h" });
|
||||
expect(state).toBe("none"); // and the listener is gone
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,10 @@ let overlayCounter = 0;
|
||||
let historyDepth = 0;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
let pendingProgrammatic = 0;
|
||||
// Overlay entries a navigation has PUSHED PAST. They are still real history entries and their
|
||||
// overlays may still be mounted, but they are no longer on top, so reclaiming one with history.back()
|
||||
// would pop the navigation instead. Reconcile consumes them silently. See closeThenNavigate.
|
||||
let buriedEntries = 0;
|
||||
let reconcileScheduled = false;
|
||||
let listenerInstalled = false;
|
||||
|
||||
@@ -153,10 +157,17 @@ let listenerInstalled = false;
|
||||
function reconcileHistory(): void {
|
||||
reconcileScheduled = false;
|
||||
const want = overlayStack.length;
|
||||
// A genuine user Back decrements historyDepth on its own, so a burial can outlive the entries it
|
||||
// described; never claim more buried entries than there are entries.
|
||||
if (buriedEntries > historyDepth) buriedEntries = historyDepth;
|
||||
while (historyDepth < want) {
|
||||
historyDepth++;
|
||||
window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
|
||||
}
|
||||
while (historyDepth > want && buriedEntries > 0) {
|
||||
historyDepth--; // buried: the browser entry stays, but it is not ours to reclaim
|
||||
buriedEntries--;
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
pendingProgrammatic++;
|
||||
@@ -212,14 +223,15 @@ export function closeThenNavigate(close: () => void, navigate: () => void): void
|
||||
window.removeEventListener("popstate", onPop);
|
||||
clearTimeout(fallback);
|
||||
navigate();
|
||||
// Any overlay entry still counted in `historyDepth` is now BURIED under the entry the navigation
|
||||
// just pushed, so a later unmount must NOT try to reclaim it with history.back() — that pops the
|
||||
// navigation instead and silently bounces the user back where they started. Found with a palette
|
||||
// opened over the player (E10 review round 1): closing the palette landed its own popstate, the
|
||||
// navigation then unmounted the player too, and the player's reconcile ate the new page entry.
|
||||
// Adopting the entries (rather than backing over them) leaves Back meaning "return to where I
|
||||
// was", which is what a navigation should mean anyway.
|
||||
historyDepth = 0;
|
||||
// Every overlay entry still counted is now BURIED under the entry the navigation just pushed, so
|
||||
// a later unmount must NOT reclaim it with history.back() — that pops the navigation instead and
|
||||
// silently bounces the user back where they started (a palette opened over the player: closing
|
||||
// the palette landed its popstate, the navigation unmounted the player too, and the player's
|
||||
// reconcile ate the new page entry). MARK them rather than zeroing the depth: round 1 set
|
||||
// `historyDepth = 0` outright, which also desynced the counter from overlays the navigation does
|
||||
// NOT unmount — About/ReleaseNotes live outside the page column — so one Back both navigated and
|
||||
// closed them (round 2).
|
||||
buriedEntries = historyDepth;
|
||||
};
|
||||
const onPop = () => {
|
||||
// The same reconcile can issue MORE than one back(); a pushState made between them would be
|
||||
|
||||
@@ -192,9 +192,17 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
|
||||
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
|
||||
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
|
||||
* context menu hand-rolled `e.key === "F10" && e.shiftKey`, so Ctrl+Shift+F10 opened it too and
|
||||
* editing the row changed the help sheet and nothing else (E10 review round 1). */
|
||||
export function matchesShortcut(e: KeyboardEvent, id: string): boolean {
|
||||
const row = ROWS.find((s) => s.id === id);
|
||||
* editing the row changed the help sheet and nothing else (E10 review round 1).
|
||||
*
|
||||
* Keyed on scope AND id, both as literal types: ids are only unique WITHIN a scope (`back` is on
|
||||
* both players), so a bare `ROWS.find(s => s.id === id)` would answer for whichever came first, and
|
||||
* a typo'd string would silently never match (round 2). */
|
||||
export function matchesShortcut<S extends KeyScope>(
|
||||
e: KeyboardEvent,
|
||||
scope: S,
|
||||
id: Extract<Row, { scope: S }>["id"],
|
||||
): boolean {
|
||||
const row = ROWS.find((s) => s.scope === scope && s.id === id);
|
||||
return !!row?.keys.some((k) => matches(e, k));
|
||||
}
|
||||
|
||||
@@ -245,7 +253,11 @@ const tokens: { scope: KeyScope }[] = [];
|
||||
/** The live scopes, innermost first, always ending in `global`. Re-renders on change. */
|
||||
export function useActiveScopes(): KeyScope[] {
|
||||
const stack = scopeStack.use();
|
||||
return [...stack].reverse().concat("global");
|
||||
// DEDUPED: a scope can be registered by more than one live component (the Playlists page and its
|
||||
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
|
||||
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
|
||||
// (E10 review round 2). Innermost registration wins the ordering.
|
||||
return [...new Set([...stack].reverse())].concat("global");
|
||||
}
|
||||
|
||||
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user