Memoize per-Geography style objects with useMemo so React.memo can skip re-renders when only the tooltip position changes. Stabilize mouse event handlers with useCallback using data-* attributes instead of per-Geography closures. This eliminates the state-update race condition that caused hover fill colors to flash back to defaults.
87 lines
5.3 KiB
Markdown
87 lines
5.3 KiB
Markdown
# BanGUI — Task List
|
||
|
||
This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.
|
||
|
||
Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
||
|
||
---
|
||
|
||
## Open Issues
|
||
|
||
### ~~WorldMap hover highlight: fill color sometimes does not change~~ ✅ Done
|
||
|
||
**Status:** Completed.
|
||
|
||
**Summary:** Memoized Geography style objects via `useMemo` keyed by `geographies`, `countries`, `selectedCountry`, and threshold props. Stabilized `onMouseEnter`, `onMouseMove`, and `onMouseLeave` handlers with `useCallback` using `data-*` attributes to avoid per-Geography closures. This ensures `React.memo(Geography)` correctly skips re-renders when only tooltip position changes, eliminating the state-update race that caused hover fills to flash back to defaults.
|
||
|
||
**Symptom:** On mouse-over, a country's stroke correctly becomes bold (`strokeWidth: 1`), but the background fill colour sometimes stays at the default value instead of switching to the hover colour.
|
||
|
||
**Root cause — `React.memo` is defeated by new style objects on every render:**
|
||
|
||
`Geography.js` (`react-simple-maps-main/src/components/Geography.js`) is wrapped in `memo()`. It keeps an internal `isFocused` state that toggles between `style.default` and `style.hover`. This works correctly in isolation.
|
||
|
||
However, in `WorldMap.tsx` (`frontend/src/components/WorldMap.tsx`):
|
||
|
||
1. **`onMouseMove`** calls `setTooltip(...)` on every pixel of mouse movement, triggering a GeoLayer re-render.
|
||
2. Each GeoLayer render creates a **new `style` object literal** for every `<Geography>` (lines ~199–222). Because `memo()` does a shallow reference comparison, it sees a "new" `style` prop every time and **re-renders all ~200 Geography components** on every mouse-move event.
|
||
3. During these rapid cascading re-renders, React's batched state updates can cause `Geography`'s internal `setFocus(true)` (fired in `handleMouseEnter`) and the parent's `setTooltip` to race. When the parent re-render is processed before `isFocused` is committed, Geography momentarily renders with `isFocused = false` and picks `style.default` — the fill stays at the default colour while the stroke (which changes less dramatically) appears correct.
|
||
|
||
A secondary contributing factor: for countries with `count === 0` that are not selected, `style.hover.fill` equals `style.default.fill` (both resolve to `fillColor`), so there is intentionally no visible fill change on hover for those countries. This may overlap with the perceived bug if the user expects all countries to highlight.
|
||
|
||
**Fix — memoize the style objects so `memo()` can skip re-renders:**
|
||
|
||
In `WorldMap.tsx`, compute each Geography's style object with `useMemo` (or move it outside the `.map()` callback with stable references). Concretely:
|
||
|
||
1. **Create a `useMemo`-based style map** keyed by `geo.rsmKey` that only recomputes when `countries`, `selectedCountry`, or the threshold props change — not on every tooltip update.
|
||
|
||
```tsx
|
||
const styleMap = useMemo(() => {
|
||
const map: Record<string, { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties }> = {};
|
||
for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
|
||
const numericId = String(geo.id);
|
||
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||
const count = cc !== null ? (countries[cc] ?? 0) : 0;
|
||
const isSelected = cc !== null && selectedCountry === cc;
|
||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||
map[geo.rsmKey] = {
|
||
default: {
|
||
fill: isSelected ? tokens.colorBrandBackground : fillColor,
|
||
stroke: tokens.colorNeutralStroke2,
|
||
strokeWidth: 0.75,
|
||
outline: "none",
|
||
},
|
||
hover: {
|
||
fill: isSelected
|
||
? tokens.colorBrandBackgroundHover
|
||
: cc && count > 0
|
||
? tokens.colorNeutralBackground3
|
||
: fillColor,
|
||
stroke: tokens.colorNeutralStroke1,
|
||
strokeWidth: 1,
|
||
outline: "none",
|
||
},
|
||
pressed: {
|
||
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
|
||
stroke: tokens.colorBrandStroke1,
|
||
strokeWidth: 1,
|
||
outline: "none",
|
||
},
|
||
};
|
||
}
|
||
return map;
|
||
}, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]);
|
||
```
|
||
|
||
2. **Pass the memoized style** in the `.map()` callback:
|
||
```tsx
|
||
<Geography style={styleMap[geo.rsmKey]} ... />
|
||
```
|
||
|
||
3. **Wrap event handlers in `useCallback`** (`onMouseEnter`, `onMouseMove`, `onMouseLeave`) or move them outside the `.map()` so they are stable references and do not defeat `memo()`. Consider passing `cc`, `count`, and `countryNames` as data attributes and reading them from `e.currentTarget` inside a single shared handler.
|
||
|
||
With stable `style` references, `memo(Geography)` will correctly skip re-renders for countries whose data has not changed, eliminating the state-update race condition and the unnecessary rendering of ~200 SVG paths on every mouse-move event.
|
||
|
||
**Files to change:**
|
||
- `frontend/src/components/WorldMap.tsx` — memoize style objects and stabilize event handlers.
|
||
- No changes needed in `react-simple-maps-main/src/components/Geography.js`.
|