From d8160a6f33f339ba2116f3e98982c72a3b4ead89 Mon Sep 17 00:00:00 2001 From: esthertitilayo-dev Date: Wed, 29 Jul 2026 23:02:04 +0100 Subject: [PATCH] fix: downsample YieldBreakdownChart and memoize chart components for long ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APYTrendChart and VaultPerformanceChart already downsample their series via sampleChartSeries() before handing data to Recharts, but YieldBreakdownChart never got the same treatment, so an "ALL" range on a long-lived vault would render every raw point instead of the shared 120-point cap. Bring it in line with the other two charts, disabling animation on downsampled series the same way, and keep the displayed period total computed from the full (undownsampled) data so it stays accurate. Also wrap all three chart components in React.memo, since none of their props change identity across unrelated parent re-renders (APYTrendChart's default data is a stable module constant, VaultPerformanceChart takes no props, and YieldBreakdownChart's only prop is a primitive number) — this avoids repeat Recharts SVG re-renders on every unrelated poll/tick elsewhere in the tree, which matters most for the largest, longest-range series. Related to #1041, which duplicates #982 (already resolved by #1027 for the other two charts). --- frontend/src/components/APYTrendChart.tsx | 2 +- .../src/components/VaultPerformanceChart.tsx | 2 +- .../components/YieldBreakdownChart.test.tsx | 81 +++++++++++++++++++ .../src/components/YieldBreakdownChart.tsx | 40 +++++++-- 4 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/YieldBreakdownChart.test.tsx diff --git a/frontend/src/components/APYTrendChart.tsx b/frontend/src/components/APYTrendChart.tsx index 556987c7..dbde2a12 100644 --- a/frontend/src/components/APYTrendChart.tsx +++ b/frontend/src/components/APYTrendChart.tsx @@ -410,4 +410,4 @@ const APYTrendChart: React.FC = ({ data = ALL_HISTORY }) => ); }; -export default APYTrendChart; +export default React.memo(APYTrendChart); diff --git a/frontend/src/components/VaultPerformanceChart.tsx b/frontend/src/components/VaultPerformanceChart.tsx index 692b68b2..92f68100 100644 --- a/frontend/src/components/VaultPerformanceChart.tsx +++ b/frontend/src/components/VaultPerformanceChart.tsx @@ -258,4 +258,4 @@ const VaultPerformanceChart: React.FC = () => { ); }; -export default VaultPerformanceChart; +export default React.memo(VaultPerformanceChart); diff --git a/frontend/src/components/YieldBreakdownChart.test.tsx b/frontend/src/components/YieldBreakdownChart.test.tsx new file mode 100644 index 00000000..187b42dc --- /dev/null +++ b/frontend/src/components/YieldBreakdownChart.test.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import YieldBreakdownChart from "../components/YieldBreakdownChart"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("../context/PreferencesContext", () => ({ + usePreferencesContext: () => ({ + preferences: { locale: "en-US", currency: "USD" }, + chartModes: { vaultPerformance: "area", apyTrend: "line", yieldBreakdown: "line" }, + setChartMode: vi.fn(), + tableDensity: "comfortable", + setTableDensity: vi.fn(), + }), +})); + +// recharts ResizeObserver shim (jsdom doesn't implement it) +vi.mock("recharts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("YieldBreakdownChart", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "test"); + }); + + it("renders the section heading", () => { + render(); + expect(screen.getByRole("heading", { name: /yield earnings/i })).toBeInTheDocument(); + }); + + it("defaults to the 30D period", () => { + render(); + const group = screen.getByRole("group", { name: /select yield period/i }); + const btn = Array.from(group.querySelectorAll("button")).find( + (b) => b.textContent === "30D", + ); + expect(btn).toHaveAttribute("aria-pressed", "true"); + }); + + it("switches to the ALL period, spanning the full 90-day mock series, without crashing", () => { + render(); + const group = screen.getByRole("group", { name: /select yield period/i }); + const btnAll = Array.from(group.querySelectorAll("button")).find( + (b) => b.textContent === "ALL", + ) as HTMLElement; + fireEvent.click(btnAll); + expect(btnAll).toHaveAttribute("aria-pressed", "true"); + expect(document.querySelector("svg")).not.toBeNull(); + }); + + it("shows the empty state and does not render a chart when there is no gain", () => { + render(); + expect(screen.getByText(/no yield data yet/i)).toBeInTheDocument(); + }); + + it("keeps the period total consistent regardless of point-sampling for rendering", () => { + // The chart downsamples the rendered series for long ranges (see sampleChartSeries), + // but the displayed total must reflect the full underlying dataset, not the + // downsampled one. + render(); + const group = screen.getByRole("group", { name: /select yield period/i }); + const btnAll = Array.from(group.querySelectorAll("button")).find( + (b) => b.textContent === "ALL", + ) as HTMLElement; + fireEvent.click(btnAll); + // 3650 total gain over 90 days averages ~40.5/day; the displayed total for + // the full period should be close to the full totalGain, not a fraction of + // it truncated by rendering-only downsampling. + expect(screen.getByText(/earned in selected period/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/YieldBreakdownChart.tsx b/frontend/src/components/YieldBreakdownChart.tsx index 3ff6be52..27b27427 100644 --- a/frontend/src/components/YieldBreakdownChart.tsx +++ b/frontend/src/components/YieldBreakdownChart.tsx @@ -18,6 +18,9 @@ import { ChartModeToggle } from "./ChartModeToggle"; import { usePreferencesContext } from "../context/PreferencesContext"; import { formatCurrency, formatDate } from "../lib/formatters"; import { formatChartCurrency, createChartCurrencyTickFormatter } from "../lib/chartFormatters"; +import { sampleChartSeries } from "../lib/chartSeries"; + +const MAX_RENDER_POINTS = 120; interface YieldDataPoint { date: string; @@ -100,15 +103,22 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) const allData = useMemo(() => generateYieldData(totalGain, 90), [totalGain]); - const data = useMemo(() => { + const filteredData = useMemo(() => { const days = PERIOD_DAYS[period]; if (days === null) return allData; return allData.slice(-days); }, [allData, period]); + const data = useMemo( + () => sampleChartSeries(filteredData, MAX_RENDER_POINTS), + [filteredData], + ); + + const isCompactSeries = filteredData.length > data.length; + const periodTotal = useMemo( - () => data.reduce((sum, p) => sum + p.yield, 0), - [data], + () => filteredData.reduce((sum, p) => sum + p.yield, 0), + [filteredData], ); const isEmpty = totalGain === 0; @@ -212,7 +222,13 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) formatDate(str, { month: "short", day: "numeric" }, locale)} minTickGap={28} /> ; label?: string }) => } /> - + ) : chartMode === "area" ? ( @@ -220,7 +236,16 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) formatDate(str, { month: "short", day: "numeric" }, locale)} minTickGap={28} /> ; label?: string }) => } /> - + ) : ( = ({ totalGain }) strokeWidth={2} dot={false} activeDot={{ r: 4, fill: "var(--accent-purple)" }} - animationDuration={600} + isAnimationActive={!isCompactSeries} + animationDuration={isCompactSeries ? 0 : 600} /> )} @@ -277,4 +303,4 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) ); }; -export default YieldBreakdownChart; +export default React.memo(YieldBreakdownChart);