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);
+ });
+});