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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user