Skip to content

Commit 23fe522

Browse files
authored
fix: mobile hover and cascading collapse in SlotProgressTimeline (#380)
* fix: handle mobile hover and cascading collapse in SlotProgressTimeline - Add useCanHover() hook to detect touch-only devices and disable hover tooltips on mobile - Fix visibility filter to hide all descendants of collapsed spans, not just direct children - Prevents orphaned child spans from appearing when parent is collapsed - Tested on desktop and verified cascading collapse behavior works correctly * refactor: move useCanHover hook to shared hooks location Extract the useCanHover hook from SlotProgressTimeline to src/hooks/useCanHover/ for reuse across the app. This hook detects if the device supports hover interactions using the CSS media query (hover: hover).
1 parent 4b1c517 commit 23fe522

File tree

5 files changed

+172
-48
lines changed

5 files changed

+172
-48
lines changed

src/hooks/useCanHover/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useCanHover } from './useCanHover';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useCanHover } from './useCanHover';
4+
5+
describe('useCanHover', () => {
6+
let matchMediaMock: ReturnType<typeof vi.fn>;
7+
let addEventListenerMock: ReturnType<typeof vi.fn>;
8+
let removeEventListenerMock: ReturnType<typeof vi.fn>;
9+
10+
beforeEach(() => {
11+
addEventListenerMock = vi.fn();
12+
removeEventListenerMock = vi.fn();
13+
matchMediaMock = vi.fn().mockReturnValue({
14+
matches: true,
15+
addEventListener: addEventListenerMock,
16+
removeEventListener: removeEventListenerMock,
17+
});
18+
window.matchMedia = matchMediaMock;
19+
});
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
25+
it('returns true when device supports hover', () => {
26+
matchMediaMock.mockReturnValue({
27+
matches: true,
28+
addEventListener: addEventListenerMock,
29+
removeEventListener: removeEventListenerMock,
30+
});
31+
32+
const { result } = renderHook(() => useCanHover());
33+
expect(result.current).toBe(true);
34+
});
35+
36+
it('returns false when device does not support hover', () => {
37+
matchMediaMock.mockReturnValue({
38+
matches: false,
39+
addEventListener: addEventListenerMock,
40+
removeEventListener: removeEventListenerMock,
41+
});
42+
43+
const { result } = renderHook(() => useCanHover());
44+
expect(result.current).toBe(false);
45+
});
46+
47+
it('subscribes to media query changes', () => {
48+
renderHook(() => useCanHover());
49+
expect(addEventListenerMock).toHaveBeenCalledWith('change', expect.any(Function));
50+
});
51+
52+
it('unsubscribes on unmount', () => {
53+
const { unmount } = renderHook(() => useCanHover());
54+
unmount();
55+
expect(removeEventListenerMock).toHaveBeenCalledWith('change', expect.any(Function));
56+
});
57+
58+
it('updates when media query changes', () => {
59+
let changeHandler: (e: MediaQueryListEvent) => void = () => {};
60+
addEventListenerMock.mockImplementation((event: string, handler: (e: MediaQueryListEvent) => void) => {
61+
if (event === 'change') {
62+
changeHandler = handler;
63+
}
64+
});
65+
66+
matchMediaMock.mockReturnValue({
67+
matches: true,
68+
addEventListener: addEventListenerMock,
69+
removeEventListener: removeEventListenerMock,
70+
});
71+
72+
const { result } = renderHook(() => useCanHover());
73+
expect(result.current).toBe(true);
74+
75+
act(() => {
76+
changeHandler({ matches: false } as MediaQueryListEvent);
77+
});
78+
79+
expect(result.current).toBe(false);
80+
});
81+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useState, useEffect } from 'react';
2+
3+
/**
4+
* Hook to detect if the device supports hover interactions.
5+
* Returns false on touch-only devices (mobile/tablet).
6+
*
7+
* Uses the CSS media query `(hover: hover)` which returns true only
8+
* for devices with a primary pointing device that can hover.
9+
*/
10+
export function useCanHover(): boolean {
11+
const [canHover, setCanHover] = useState(() => {
12+
if (typeof window === 'undefined') return true;
13+
return window.matchMedia('(hover: hover)').matches;
14+
});
15+
16+
useEffect(() => {
17+
const mediaQuery = window.matchMedia('(hover: hover)');
18+
setCanHover(mediaQuery.matches);
19+
20+
const handler = (e: MediaQueryListEvent): void => setCanHover(e.matches);
21+
mediaQuery.addEventListener('change', handler);
22+
return () => mediaQuery.removeEventListener('change', handler);
23+
}, []);
24+
25+
return canHover;
26+
}

src/pages/ethereum/slots/components/SlotProgressTimeline/SlotProgressTimeline.tsx

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Card } from '@/components/Layout/Card';
44
import { ClientLogo } from '@/components/Ethereum/ClientLogo';
55
import { SelectMenu } from '@/components/Forms/SelectMenu';
66
import { Toggle } from '@/components/Forms/Toggle';
7+
import { useCanHover } from '@/hooks/useCanHover';
78
import type { SlotProgressTimelineProps, TraceSpan } from './SlotProgressTimeline.types';
89
import { SPAN_COLORS } from './constants';
910
import { formatMs, msToPercent } from './utils';
@@ -38,6 +39,7 @@ export function SlotProgressTimeline({
3839
contributor,
3940
onContributorChange,
4041
}: SlotProgressTimelineProps): JSX.Element {
42+
const canHover = useCanHover();
4143
const [hoveredSpan, setHoveredSpan] = useState<TraceSpan | null>(null);
4244
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
4345
const [collapsedSpans, setCollapsedSpans] = useState<Set<string>>(new Set());
@@ -122,22 +124,31 @@ export function SlotProgressTimeline({
122124
hasInitializedCollapsed.current = false;
123125
}, [selectedUsername]);
124126

125-
// Handle mouse events for tooltip
126-
const handleMouseEnter = useCallback((span: TraceSpan) => {
127-
setHoveredSpan(span);
128-
}, []);
127+
// Handle mouse events for tooltip (disabled on touch devices)
128+
const handleMouseEnter = useCallback(
129+
(span: TraceSpan) => {
130+
if (!canHover) return;
131+
setHoveredSpan(span);
132+
},
133+
[canHover]
134+
);
129135

130136
const handleMouseLeave = useCallback(() => {
137+
if (!canHover) return;
131138
setHoveredSpan(null);
132139
setMousePos(null);
133-
}, []);
140+
}, [canHover]);
134141

135-
const handleMouseMove = useCallback((e: React.MouseEvent) => {
136-
if (containerRef.current) {
137-
const rect = containerRef.current.getBoundingClientRect();
138-
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
139-
}
140-
}, []);
142+
const handleMouseMove = useCallback(
143+
(e: React.MouseEvent) => {
144+
if (!canHover) return;
145+
if (containerRef.current) {
146+
const rect = containerRef.current.getBoundingClientRect();
147+
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
148+
}
149+
},
150+
[canHover]
151+
);
141152

142153
// Loading state
143154
if (isLoading || executionLoading) {
@@ -173,8 +184,20 @@ export function SlotProgressTimeline({
173184
...availableUsernames.map(username => ({ value: username, label: username })),
174185
];
175186

176-
// Filter spans by collapsed state
177-
const visibleSpans = spans.filter(span => !span.parentId || !collapsedSpans.has(span.parentId));
187+
// Filter spans by collapsed state - hide if any ancestor is collapsed
188+
// Build a lookup map for O(1) parent access
189+
const spanById = new Map(spans.map(s => [s.id, s]));
190+
const visibleSpans = spans.filter(span => {
191+
if (!span.parentId) return true;
192+
193+
// Walk up the ancestry chain to check if any ancestor is collapsed
194+
let currentParentId: string | undefined = span.parentId;
195+
while (currentParentId) {
196+
if (collapsedSpans.has(currentParentId)) return false;
197+
currentParentId = spanById.get(currentParentId)?.parentId;
198+
}
199+
return true;
200+
});
178201

179202
return (
180203
<Card>

src/pages/ethereum/slots/components/SlotProgressTimeline/useTraceSpans.ts

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
FctMevBidHighestValueByBuilderChunked50Ms,
1212
} from '@/api/types.gen';
1313
import { SLOT_DURATION_MS, MAX_REASONABLE_SEEN_TIME_MS } from './constants';
14-
import { formatMs, classificationToCategory, filterOutliers } from './utils';
14+
import { formatMs, classificationToCategory, calculateOutlierBounds } from './utils';
1515
import type { TraceSpan } from './SlotProgressTimeline.types';
1616

1717
interface UseTraceSpansOptions {
@@ -455,31 +455,38 @@ function buildBlockProposalSpans(
455455
): void {
456456
if (nodeTimelines.length === 0) return;
457457

458-
// Calculate overall timing bounds
459-
const allStartTimes = nodeTimelines.map(n => n.blockReceivedMs);
460-
const allEndTimes = nodeTimelines.map(n => {
461-
// Find the latest event for each node
458+
// Calculate end time for each node
459+
const getNodeEndTime = (n: NodeTimeline): number => {
462460
const times = [n.blockReceivedMs];
463461
if (n.headUpdatedMs !== null) times.push(n.headUpdatedMs);
464462
if (n.execution) times.push(n.execution.endMs);
465463
if (n.daCompleteMs !== null) times.push(n.daCompleteMs);
466464
return Math.max(...times);
467-
});
468-
469-
const firstSeenMs = Math.min(...allStartTimes);
470-
let lastEventMs = Math.max(...allEndTimes);
465+
};
471466

472-
// Apply outlier filtering for parent span
473-
if (excludeOutliers && allEndTimes.length >= 4) {
474-
const filteredEndTimes = filterOutliers(allEndTimes);
475-
if (filteredEndTimes.length > 0) {
476-
lastEventMs = Math.max(...filteredEndTimes);
467+
// Filter out outlier nodes when excludeOutliers is enabled
468+
let filteredTimelines = nodeTimelines;
469+
if (excludeOutliers && nodeTimelines.length >= 4) {
470+
const allEndTimes = nodeTimelines.map(getNodeEndTime);
471+
const bounds = calculateOutlierBounds(allEndTimes);
472+
if (bounds) {
473+
filteredTimelines = nodeTimelines.filter(n => {
474+
const endTime = getNodeEndTime(n);
475+
return endTime <= bounds.upper;
476+
});
477477
}
478478
}
479479

480-
// Group by country
480+
// Use filtered timelines for all calculations
481+
const allStartTimes = filteredTimelines.map(n => n.blockReceivedMs);
482+
const allEndTimes = filteredTimelines.map(getNodeEndTime);
483+
484+
const firstSeenMs = Math.min(...allStartTimes);
485+
const lastEventMs = Math.max(...allEndTimes);
486+
487+
// Group by country (using filtered timelines)
481488
const byCountry = new Map<string, NodeTimeline[]>();
482-
for (const timeline of nodeTimelines) {
489+
for (const timeline of filteredTimelines) {
483490
const existing = byCountry.get(timeline.country) || [];
484491
existing.push(timeline);
485492
byCountry.set(timeline.country, existing);
@@ -501,8 +508,8 @@ function buildBlockProposalSpans(
501508
category: 'propagation',
502509
depth: 1,
503510
details: selectedUsername
504-
? `${selectedUsername}: ${nodeTimelines.length} node${nodeTimelines.length !== 1 ? 's' : ''} processed block`
505-
: `Block proposal lifecycle across ${nodeTimelines.length} nodes in ${sortedCountries.length} countries`,
511+
? `${selectedUsername}: ${filteredTimelines.length} node${filteredTimelines.length !== 1 ? 's' : ''} processed block`
512+
: `Block proposal lifecycle across ${filteredTimelines.length} nodes in ${sortedCountries.length} countries`,
506513
isLate: lastEventMs > ATTESTATION_DEADLINE_MS,
507514
collapsible: sortedCountries.length > 0,
508515
defaultCollapsed: false,
@@ -517,21 +524,8 @@ function buildBlockProposalSpans(
517524

518525
// Calculate country span bounds
519526
const countryFirstSeen = Math.min(...sortedNodes.map(n => n.blockReceivedMs));
520-
const countryEndTimes = sortedNodes.map(n => {
521-
const times = [n.blockReceivedMs];
522-
if (n.headUpdatedMs !== null) times.push(n.headUpdatedMs);
523-
if (n.execution) times.push(n.execution.endMs);
524-
if (n.daCompleteMs !== null) times.push(n.daCompleteMs);
525-
return Math.max(...times);
526-
});
527-
528-
let countryLastEvent = Math.max(...countryEndTimes);
529-
if (excludeOutliers && countryEndTimes.length >= 4) {
530-
const filteredTimes = filterOutliers(countryEndTimes);
531-
if (filteredTimes.length > 0) {
532-
countryLastEvent = Math.max(...filteredTimes);
533-
}
534-
}
527+
const countryEndTimes = sortedNodes.map(getNodeEndTime);
528+
const countryLastEvent = Math.max(...countryEndTimes);
535529

536530
// Country span
537531
result.push({
@@ -659,7 +653,7 @@ function buildBlockProposalSpans(
659653

660654
result.push({
661655
id: daSpanId,
662-
label: `Data Availability (${columnCount})`,
656+
label: 'Data Availability',
663657
startMs: daStartMs,
664658
endMs: timeline.daCompleteMs,
665659
category: 'column',
@@ -669,7 +663,6 @@ function buildBlockProposalSpans(
669663
parentId: nodeId,
670664
collapsible: columnCount > 1,
671665
defaultCollapsed: true,
672-
// nodeCount omitted - already shown in label
673666
});
674667

675668
// Add child spans for each individual column/blob (only if multiple)

0 commit comments

Comments
 (0)