Memoize Fluent chart token resolution

This commit is contained in:
2026-04-20 19:47:10 +02:00
parent 20412dd94b
commit 27369b43d6
9 changed files with 140 additions and 18 deletions

View File

@@ -168,7 +168,11 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr
---
### TASK-009 — `resolveFluentToken` calls `getComputedStyle` on every render
### TASK-009 — `resolveFluentToken` calls `getComputedStyle` on every render (done)
**Where fixed:** `frontend/src/components/BanTrendChart.tsx`, `frontend/src/components/TopCountriesPieChart.tsx`, `frontend/src/components/TopCountriesBarChart.tsx`, `frontend/src/components/JailDistributionChart.tsx`
**Summary:** Wrapped Fluent token resolution calls in `useMemo([])` in each chart component and added tests verifying token resolution is memoized across rerenders.
**Where found:** `frontend/src/utils/chartTheme.ts`, `resolveFluentToken` function. Called 23 times per render in `BanTrendChart`, `TopCountriesPieChart`, `TopCountriesBarChart`, and `JailDistributionChart`.

View File

@@ -4,6 +4,7 @@
* Calls `useBanTrend` internally and handles loading, error, and empty states.
*/
import { useMemo } from "react";
import {
Area,
AreaChart,
@@ -134,8 +135,14 @@ function buildEntries(
// Custom tooltip
// ---------------------------------------------------------------------------
function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
const { active, payload } = props;
interface TrendTooltipProps extends Partial<TooltipContentProps> {
backgroundColor: string;
borderColor: string;
textColor: string;
}
function TrendTooltip(props: TrendTooltipProps): React.JSX.Element | null {
const { active, payload = [], backgroundColor, borderColor, textColor } = props;
if (!active || payload.length === 0) return null;
const entry = payload[0];
if (entry == null) return null;
@@ -154,11 +161,11 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
backgroundColor,
border: `1px solid ${borderColor}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
color: textColor,
fontSize: "13px",
}}
>
@@ -197,11 +204,27 @@ export function BanTrendChart({
const isEmpty = buckets.every((b) => b.count === 0);
const entries = buildEntries(buckets, timeRange);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
const { primaryColour, axisColour, gridColour } = useMemo(
() => ({
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
}),
[],
);
const tickInterval = TICK_INTERVAL[timeRange];
const tooltipContent = useMemo(
() => (
<TrendTooltip
backgroundColor={resolveFluentToken(tokens.colorNeutralBackground1)}
borderColor={resolveFluentToken(tokens.colorNeutralStroke2)}
textColor={resolveFluentToken(tokens.colorNeutralForeground1)}
/>
),
[],
);
return (
<ChartStateWrapper
isLoading={isLoading}
@@ -233,7 +256,7 @@ export function BanTrendChart({
tickLine={false}
axisLine={false}
/>
<Tooltip content={TrendTooltip} />
<Tooltip content={tooltipContent} />
<Area
type="monotone"
dataKey="count"

View File

@@ -6,6 +6,7 @@
* empty states so the parent only needs to pass filter props.
*/
import { useMemo } from "react";
import {
Bar,
BarChart,
@@ -137,9 +138,14 @@ export function JailDistributionChart({
const entries = buildEntries(jails);
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
const { primaryColour, axisColour, gridColour } = useMemo(
() => ({
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
}),
[],
);
return (
<ChartStateWrapper

View File

@@ -3,6 +3,7 @@
* by ban count, sorted descending.
*/
import { useMemo } from "react";
import {
Bar,
BarChart,
@@ -144,6 +145,15 @@ export function TopCountriesBarChart({
const entries = buildEntries(countries, countryNames);
const { primaryColour, axisColour, gridColour } = useMemo(
() => ({
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
}),
[],
);
if (entries.length === 0) {
return (
<div className={styles.emptyWrapper}>
@@ -154,10 +164,6 @@ export function TopCountriesBarChart({
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
return (
<div className={styles.wrapper} style={{ height: chartHeight }}>
<ResponsiveContainer width="100%" height="100%">

View File

@@ -3,6 +3,7 @@
* an "Other" slice aggregating all remaining countries.
*/
import { useMemo } from "react";
import {
Cell,
Legend,
@@ -134,7 +135,10 @@ export function TopCountriesPieChart({
}: TopCountriesPieChartProps): React.JSX.Element {
const styles = useStyles();
const resolvedPalette = CHART_PALETTE.map(resolveFluentToken);
const resolvedPalette = useMemo(
() => CHART_PALETTE.map(resolveFluentToken),
[],
);
const slices = buildSlices(countries, countryNames, resolvedPalette);
const total = slices.reduce((sum, s) => sum + s.value, 0);

View File

@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BanTrendChart } from "../BanTrendChart";
import * as chartTheme from "../../utils/chartTheme";
import * as useBanTrendModule from "../../hooks/useBanTrend";
import type { UseBanTrendResult } from "../../hooks/useBanTrend";
import type { BanTrendBucket } from "../../types/ban";
@@ -87,4 +88,22 @@ describe("BanTrendChart", () => {
wrap(<BanTrendChart timeRange="24h" origin="all" />);
expect(screen.getByTestId("area-chart")).toBeInTheDocument();
});
it("memoizes token resolution across rerenders", () => {
const spy = vi.spyOn(chartTheme, "resolveFluentToken");
const props = { timeRange: "24h" as const, origin: "all" as const };
mockHook({ buckets: [{ timestamp: "2024-01-01T00:00:00Z", count: 5 }] });
const { rerender } = wrap(<BanTrendChart {...props} />);
expect(spy).toHaveBeenCalledTimes(6);
rerender(
<FluentProvider theme={webLightTheme}>
<BanTrendChart {...props} />
</FluentProvider>,
);
expect(spy).toHaveBeenCalledTimes(6);
spy.mockRestore();
});
});

View File

@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { JailDistributionChart } from "../JailDistributionChart";
import * as chartTheme from "../../utils/chartTheme";
import * as useJailDistributionModule from "../../hooks/useJailDistribution";
import type { UseJailDistributionResult } from "../../hooks/useJailDistribution";
@@ -84,4 +85,21 @@ describe("JailDistributionChart", () => {
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("memoizes token resolution across rerenders", () => {
const spy = vi.spyOn(chartTheme, "resolveFluentToken");
const props = { timeRange: "24h" as const, origin: "all" as const };
const { rerender } = wrap(<JailDistributionChart {...props} />);
expect(spy).toHaveBeenCalledTimes(3);
rerender(
<FluentProvider theme={webLightTheme}>
<JailDistributionChart {...props} />
</FluentProvider>,
);
expect(spy).toHaveBeenCalledTimes(3);
spy.mockRestore();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { TopCountriesBarChart } from "../TopCountriesBarChart";
import * as chartTheme from "../../utils/chartTheme";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
@@ -41,6 +42,26 @@ describe("TopCountriesBarChart", () => {
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("memoizes token resolution across rerenders", () => {
const spy = vi.spyOn(chartTheme, "resolveFluentToken");
const props = {
countries: { DE: 50, US: 30, FR: 15 },
countryNames: { DE: "Germany", US: "United States", FR: "France" },
};
const { rerender } = wrap(<TopCountriesBarChart {...props} />);
expect(spy).toHaveBeenCalledTimes(3);
rerender(
<FluentProvider theme={webLightTheme}>
<TopCountriesBarChart {...props} />
</FluentProvider>,
);
expect(spy).toHaveBeenCalledTimes(3);
spy.mockRestore();
});
it("does not render more than 20 bars (TOP_N limit)", () => {
// Build 30 countries — only top 20 should appear in the chart
const countries: Record<string, number> = {};

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { TopCountriesPieChart } from "../TopCountriesPieChart";
import * as chartTheme from "../../utils/chartTheme";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
@@ -38,4 +39,24 @@ describe("TopCountriesPieChart", () => {
);
expect(screen.getByTestId("pie-chart")).toBeInTheDocument();
});
it("memoizes palette token resolution across rerenders", () => {
const spy = vi.spyOn(chartTheme, "resolveFluentToken");
const props = {
countries: { DE: 30, US: 20, CN: 10 },
countryNames: { DE: "Germany", US: "United States", CN: "China" },
};
const { rerender } = wrap(<TopCountriesPieChart {...props} />);
expect(spy).toHaveBeenCalledTimes(5);
rerender(
<FluentProvider theme={webLightTheme}>
<TopCountriesPieChart {...props} />
</FluentProvider>,
);
expect(spy).toHaveBeenCalledTimes(5);
spy.mockRestore();
});
});