diff --git a/frontend/src/components/ChannelInfoPane.tsx b/frontend/src/components/ChannelInfoPane.tsx index 8c63da6..263bc70 100644 --- a/frontend/src/components/ChannelInfoPane.tsx +++ b/frontend/src/components/ChannelInfoPane.tsx @@ -4,6 +4,7 @@ import { Star } from 'lucide-react'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; import { handleKeyboardActivate } from '../utils/a11y'; +import { useEntranceSettled } from '../hooks/useEntranceSettled'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { toast } from './ui/sonner'; import type { Channel, ChannelDetail, PathHashWidthStats } from '../types'; @@ -28,6 +29,10 @@ export function ChannelInfoPane({ // Get live channel data from channels array (real-time via WS) const liveChannel = channelKey ? (channels.find((c) => c.key === channelKey) ?? null) : null; + // Defer mounting the Recharts pie until the pane's slide-in animation settles; + // mounting it mid-transform crashes Safari (React #185). See #317. + const chartReady = useEntranceSettled(channelKey !== null); + useEffect(() => { setShowKey(false); if (!channelKey) { @@ -181,7 +186,7 @@ export function ChannelInfoPane({ {detail && detail.path_hash_width_24h.total_packets > 0 && (
Hop Byte Widths (24h) - +
)} @@ -249,7 +254,7 @@ const TOOLTIP_STYLE = { }, } as const; -function HopWidthChart({ stats }: { stats: PathHashWidthStats }) { +function HopWidthChart({ stats, ready }: { stats: PathHashWidthStats; ready: boolean }) { const data = useMemo( () => HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({ @@ -263,32 +268,35 @@ function HopWidthChart({ stats }: { stats: PathHashWidthStats }) { return (
- - - - {data.map((d) => ( - - ))} - - { - const v = typeof value === 'number' ? value : Number(value); - return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name]; - }} - /> - - + {/* Reserve the box while the pane animates in (see #317). */} + {ready && ( + + + + {data.map((d) => ( + + ))} + + { + const v = typeof value === 'number' ? value : Number(value); + return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name]; + }} + /> + + + )}
diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index bb911a0..28125b7 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -39,6 +39,7 @@ import { LppSensorRow, formatLppLabel } from './repeater/repeaterPaneShared'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { toast } from './ui/sonner'; import { useDistanceUnit } from '../contexts/DistanceUnitContext'; +import { useEntranceSettled } from '../hooks/useEntranceSettled'; import { CONTACT_TYPE_REPEATER } from '../types'; import type { Contact, @@ -115,6 +116,10 @@ export function ContactInfoPane({ const liveContact = contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null; + // Defer mounting Recharts containers until the pane's slide-in animation + // settles; mounting them mid-transform crashes Safari (React #185). See #317. + const chartsReady = useEntranceSettled(contactKey !== null); + useEffect(() => { if (!contactKey) { setAnalytics(null); @@ -297,7 +302,7 @@ export function ContactInfoPane({
)} - + - + Messages Per Hour Messages Per Week ({ + ready, ariaLabel, points, series, @@ -813,6 +829,7 @@ function ActivityLineChart; @@ -820,6 +837,11 @@ function ActivityLineChart string; valueFormatter: (value: number) => string; }) { + // Reserve the chart's height while the pane animates in (see #317). + if (!ready) { + return
; + } + const data = points.map((point, i) => { const entry: Record = { idx: i, tick: tickFormatter(point) }; for (const s of series) { @@ -839,7 +861,7 @@ function ActivityLineChart - + setState storm that Safari resolves + * into React error #185 ("Maximum update depth exceeded"), blanking the page. + * The default delay matches the `Sheet` open animation (`duration-500`) plus a + * small buffer. See issue #317. + */ +export function useEntranceSettled(open: boolean, delayMs = 550): boolean { + const [settled, setSettled] = useState(false); + + useEffect(() => { + if (!open) { + setSettled(false); + return; + } + const id = window.setTimeout(() => setSettled(true), delayMs); + return () => window.clearTimeout(id); + }, [open, delayMs]); + + return settled; +} diff --git a/frontend/src/test/useEntranceSettled.test.ts b/frontend/src/test/useEntranceSettled.test.ts new file mode 100644 index 0000000..da22182 --- /dev/null +++ b/frontend/src/test/useEntranceSettled.test.ts @@ -0,0 +1,77 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useEntranceSettled } from '../hooks/useEntranceSettled'; + +describe('useEntranceSettled', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stays false while closed', () => { + const { result } = renderHook(() => useEntranceSettled(false, 550)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(false); + }); + + it('flips to true only after the delay once open', () => { + const { result } = renderHook(() => useEntranceSettled(true, 550)); + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(549); + }); + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBe(true); + }); + + it('does not settle if closed before the delay elapses', () => { + const { result, rerender } = renderHook(({ open }) => useEntranceSettled(open, 550), { + initialProps: { open: true }, + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(result.current).toBe(false); + + // Close before the timer fires — the pending timeout must be cancelled. + rerender({ open: false }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(false); + }); + + it('resets to false when reopened after having settled', () => { + const { result, rerender } = renderHook(({ open }) => useEntranceSettled(open, 550), { + initialProps: { open: true }, + }); + + act(() => { + vi.advanceTimersByTime(550); + }); + expect(result.current).toBe(true); + + rerender({ open: false }); + expect(result.current).toBe(false); + + // Reopening restarts the deferral rather than showing charts immediately. + rerender({ open: true }); + expect(result.current).toBe(false); + act(() => { + vi.advanceTimersByTime(550); + }); + expect(result.current).toBe(true); + }); +});