Skip to content

Commit a727e83

Browse files
srcdevclaude
andcommitted
fix(ResponsiveHeader): fix keyboard hover state leak and ResizeObserver race
- Add @focusout handler on main-navigation that clears hoveredItemKey when focus moves outside the nav entirely (checked via relatedTarget); previously, keyboard Tab past the last nav item left an item stuck in is-hovered with the indicator still visible - Guard the ResizeObserver async callback with an isMeasuring/pendingMeasure mutex using the run-latest do/while pattern; the two-pass geometry path awaits multiple ticks and toggles isOverflowVisibleForMeasurement — concurrent observer fires could race on that state and corrupt mainNavigationState Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 872da85 commit a727e83

2 files changed

Lines changed: 77 additions & 45 deletions

File tree

app/components/responsive-header/ResponsiveHeader.vue

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
:class="{ 'is-animated': isAnimated }"
77
aria-label="Main navigation"
88
@mouseleave="hoveredItemKey = null"
9+
@focusout="handleNavFocusout"
910
>
1011
<ul
1112
v-for="(navGroup, groupKey) in responsiveNavLinks"
@@ -199,6 +200,14 @@ const handleNavigationItemHover = (key: string) => {
199200
closeAllNavigationDetails();
200201
};
201202
203+
const handleNavFocusout = (event: FocusEvent) => {
204+
// Only clear when focus moves outside the nav entirely, not between nav items.
205+
const nav = event.currentTarget as HTMLElement;
206+
if (!event.relatedTarget || !nav.contains(event.relatedTarget as Node)) {
207+
hoveredItemKey.value = null;
208+
}
209+
};
210+
202211
const handleSummaryAction = (event: MouseEvent | KeyboardEvent) => {
203212
toggleDetailsElement(event);
204213
};
@@ -462,46 +471,74 @@ onMounted(async () => {
462471
setupClickOutsideListeners();
463472
});
464473
474+
// ─── Re-entrancy guard for the ResizeObserver callback ────────────────────
475+
// The observer fires an async callback that awaits multiple ticks and toggles
476+
// layout-affecting state. If the observer fires again while the first pass is
477+
// still in flight, two concurrent passes would race on isOverflowVisibleForMeasurement
478+
// and mainNavigationState.
479+
//
480+
// Pattern: "run-latest" queue.
481+
// • isMeasuring gates entry — any new observer call while a pass is running
482+
// sets pendingMeasure = true and returns immediately.
483+
// • The do/while re-runs once after the pass completes if a new resize arrived,
484+
// so we never silently drop the final geometry update.
485+
let isMeasuring = false;
486+
let pendingMeasure = false;
487+
465488
useResizeObserver(navigationWrapperRef, async () => {
466-
if (!isGeometryReady.value) {
467-
// ─── Two-pass measurement on initial mount / route change ──────────────
468-
//
469-
// We never use a "peek" check to decide whether phase 2 is needed.
470-
// A peek after phase 1 gives the wrong answer at the breakpoint because:
471-
// • updateNavigationConfig() sets secondaryNavRects reactively, but
472-
// v-bind(mainNavigationMarginBlockEndStr) isn't flushed to the DOM until
473-
// the next Vue render tick — so item rects are still measured against the
474-
// OLD margin, producing a stale DOM/computed mismatch.
475-
// • Even with correct timing, checking without the button reserved will
476-
// always pass marginal items (those that fit only when button is absent).
477-
//
478-
// Phase 1: button is 'visually-hidden' (width:0).
479-
// Measure the wrapper and secondary-nav rects without the button.
480-
await updateNavigationConfig("phase1");
481-
482-
// Phase 2: switch button to 'is-measuring' — opacity:0 / visibility:hidden
483-
// but natural width — so secondaryNavRects captures the button's real width.
484-
// The button is invisible to the user; nav items are still visually-hidden.
485-
isOverflowVisibleForMeasurement.value = true;
486-
await nextTick(); // let Vue render the 'is-measuring' class (button at natural width)
487-
await updateNavigationConfig("phase2"); // secondaryNavRects now includes button width
488-
489-
// Wait for Vue to flush the updated mainNavigationMarginBlockEndStr via v-bind
490-
// before reading item positions — without this tick, getBoundingClientRect()
491-
// in initMainNavigationState would see the pre-phase-2 margin-inline-end.
492-
await nextTick();
493-
494-
// Final visibility pass — reads item rects against the correct margin.
495-
// Also sets isGeometryReady = true.
496-
initMainNavigationState();
497-
// Hand control back to showOverflowDetails; measurement flag no longer needed.
498-
isOverflowVisibleForMeasurement.value = false;
499-
} else {
500-
// ─── Normal resize after geometry is settled ───────────────────────────
501-
// The button's state is already correct (driven by showOverflowDetails),
502-
// so a single-pass measurement gives accurate secondaryNavRects.
503-
await updateNavigationConfig("useResizeObserver");
504-
initMainNavigationState();
489+
if (isMeasuring) {
490+
pendingMeasure = true;
491+
return;
492+
}
493+
494+
isMeasuring = true;
495+
try {
496+
do {
497+
pendingMeasure = false;
498+
499+
if (!isGeometryReady.value) {
500+
// ─── Two-pass measurement on initial mount / route change ──────────────
501+
//
502+
// We never use a "peek" check to decide whether phase 2 is needed.
503+
// A peek after phase 1 gives the wrong answer at the breakpoint because:
504+
// • updateNavigationConfig() sets secondaryNavRects reactively, but
505+
// v-bind(mainNavigationMarginBlockEndStr) isn't flushed to the DOM until
506+
// the next Vue render tick — so item rects are still measured against the
507+
// OLD margin, producing a stale DOM/computed mismatch.
508+
// • Even with correct timing, checking without the button reserved will
509+
// always pass marginal items (those that fit only when button is absent).
510+
//
511+
// Phase 1: button is 'visually-hidden' (width:0).
512+
// Measure the wrapper and secondary-nav rects without the button.
513+
await updateNavigationConfig("phase1");
514+
515+
// Phase 2: switch button to 'is-measuring' — opacity:0 / visibility:hidden
516+
// but natural width — so secondaryNavRects captures the button's real width.
517+
// The button is invisible to the user; nav items are still visually-hidden.
518+
isOverflowVisibleForMeasurement.value = true;
519+
await nextTick(); // let Vue render the 'is-measuring' class (button at natural width)
520+
await updateNavigationConfig("phase2"); // secondaryNavRects now includes button width
521+
522+
// Wait for Vue to flush the updated mainNavigationMarginBlockEndStr via v-bind
523+
// before reading item positions — without this tick, getBoundingClientRect()
524+
// in initMainNavigationState would see the pre-phase-2 margin-inline-end.
525+
await nextTick();
526+
527+
// Final visibility pass — reads item rects against the correct margin.
528+
// Also sets isGeometryReady = true.
529+
initMainNavigationState();
530+
// Hand control back to showOverflowDetails; measurement flag no longer needed.
531+
isOverflowVisibleForMeasurement.value = false;
532+
} else {
533+
// ─── Normal resize after geometry is settled ───────────────────────────
534+
// The button's state is already correct (driven by showOverflowDetails),
535+
// so a single-pass measurement gives accurate secondaryNavRects.
536+
await updateNavigationConfig("useResizeObserver");
537+
initMainNavigationState();
538+
}
539+
} while (pendingMeasure);
540+
} finally {
541+
isMeasuring = false;
505542
}
506543
});
507544

app/pages/ui/simple-grid.vue

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@
66
<h1 class="page-heading-3">Simple Grid</h1>
77
<p class="page-body-normal">Simple grid displaying dummy posts data</p>
88

9-
<LayoutGridByWidth
10-
v-if="status === 'success'"
11-
column-width="300px"
12-
gap="2rem"
13-
:style-class-passthrough="['display-posts']"
14-
>
9+
<LayoutGridByWidth v-if="status === 'success'" column-width="300px" gap="2rem">
1510
<template v-for="(item, index) in postsData?.posts.slice(0, displayCount)" :key="item.id" #[item.id]>
1611
<div class="display-post-item">
1712
<div>Views: {{ item.views }}</div>

0 commit comments

Comments
 (0)