mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Defer chart render until flyin is settled. Closes #317.
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user