Defer chart render until flyin is settled. Closes #317.

This commit is contained in:
Jack Kingsman
2026-07-08 16:47:41 -07:00
parent 0c8039cc44
commit 97b873e991
4 changed files with 167 additions and 32 deletions
+36 -28
View File
@@ -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 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
<HopWidthChart stats={detail.path_hash_width_24h} />
<HopWidthChart stats={detail.path_hash_width_24h} ready={chartReady} />
</div>
)}
@@ -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 (
<div className="flex items-center gap-3">
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={22}
outerRadius={40}
strokeWidth={1.5}
stroke="hsl(var(--background))"
>
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<RechartsTooltip
{...TOOLTIP_STYLE}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const v = typeof value === 'number' ? value : Number(value);
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Reserve the box while the pane animates in (see #317). */}
{ready && (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={22}
outerRadius={40}
strokeWidth={1.5}
stroke="hsl(var(--background))"
>
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<RechartsTooltip
{...TOOLTIP_STYLE}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const v = typeof value === 'number' ? value : Number(value);
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</div>
<div className="flex-1 space-y-1">
+26 -4
View File
@@ -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({
</div>
)}
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} ready={chartsReady} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
@@ -601,7 +606,7 @@ export function ContactInfoPane({
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} ready={chartsReady} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
@@ -721,7 +726,13 @@ function MostActiveChannelsSection({
);
}
function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | null }) {
function ActivityChartsSection({
analytics,
ready,
}: {
analytics: ContactAnalytics | null;
ready: boolean;
}) {
if (!analytics) {
return null;
}
@@ -741,6 +752,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
<div>
<SectionLabel>Messages Per Hour</SectionLabel>
<ActivityLineChart
ready={ready}
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
@@ -769,6 +781,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
<div>
<SectionLabel>Messages Per Week</SectionLabel>
<ActivityLineChart
ready={ready}
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
@@ -805,7 +818,10 @@ const TOOLTIP_STYLE = {
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
const ACTIVITY_CHART_HEIGHT = 140;
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ready,
ariaLabel,
points,
series,
@@ -813,6 +829,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
tickFormatter,
valueFormatter,
}: {
ready: boolean;
ariaLabel: string;
points: T[];
series: Array<{ key: keyof T; color: string; label?: string }>;
@@ -820,6 +837,11 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
tickFormatter: (point: T) => string;
valueFormatter: (value: number) => string;
}) {
// Reserve the chart's height while the pane animates in (see #317).
if (!ready) {
return <div role="img" aria-label={ariaLabel} style={{ height: ACTIVITY_CHART_HEIGHT }} />;
}
const data = points.map((point, i) => {
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
for (const s of series) {
@@ -839,7 +861,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
return (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={140}>
<ResponsiveContainer width="100%" height={ACTIVITY_CHART_HEIGHT}>
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
/**
* Returns `false` until `open` has been continuously true for `delayMs`, then
* `true`; resets to `false` whenever `open` becomes false.
*
* Used to defer mounting layout-measuring children (notably Recharts
* `ResponsiveContainer`) inside sliding drawers until the entrance animation has
* settled. Mounting a `ResponsiveContainer` while its box is still being
* transformed drives a `ResizeObserver` -> 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;
}
@@ -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);
});
});