Skip to content

perf(dashboard): cut re-render cost + lazy-load ApexCharts#1109

Open
TaprootFreak wants to merge 5 commits into
developfrom
perf/dashboard-frontend-optimization
Open

perf(dashboard): cut re-render cost + lazy-load ApexCharts#1109
TaprootFreak wants to merge 5 commits into
developfrom
perf/dashboard-frontend-optimization

Conversation

@TaprootFreak

Copy link
Copy Markdown
Collaborator

Summary

  • Skip state updates in the financial overview when the 60s auto-refresh returns identical payloads (timestamp / append-only log tail), so ApexCharts no longer redoes SVG repaints for no-op polls.
  • Stabilize the references returned by useDashboard and the derived inputs of SimpleBarChart so existing useMemos actually short-circuit instead of invalidating every render.
  • Move react-apexcharts (~500 KB) behind React.lazy in all five dashboard chart components, with layout-stable Suspense fallbacks. Prefetch the chunk on overview mount so the first chart paint does not wait on a separate round-trip.

Pairs with the backend optimization in DFXswiss/api#3725 (financial log query latency 5-15s -> <500ms).

Changes

Fix A - skip no-op state updates (src/screens/dashboard-financial-overview.screen.tsx)

  • Add pure helpers sameLatestBalance(a, b) and sameLogEntries(a, b) exported alongside the screen for testability.
  • Switch both setters in the 60s refresh effect to the functional form so the previous reference is kept when nothing changed.

Fix C - stabilize identities

  • src/hooks/dashboard.hook.ts: move the five async functions (getFinancialLog, getFinancialChanges, getLatestBalance, getLatestChanges, getRefRecipients) inside the existing useMemo([call]) body so consumers actually get stable references across renders.
  • src/components/dashboard/latest-balance-bar-chart.tsx: collapse categories, values and colors (previously recomputed on every render and used as useMemo deps) into a single useMemo([data]).

Fix B - lazy-load ApexCharts

  • src/components/dashboard/total-balance-long-chart.tsx, latest-balance-bar-chart.tsx, balance-by-type-area-chart.tsx, financial-changes-chart.tsx, total-balance-short-chart.tsx: replace import Chart from 'react-apexcharts' with lazy(() => import('react-apexcharts')) and wrap every <Chart /> in a <Suspense fallback={<div style={{ height: N }} />}> matching the chart height (250 / 300 / 400 px) so the layout does not jump.
  • dashboard-financial-overview.screen.tsx: fire a best-effort import('react-apexcharts').catch(...) on mount so the chunk is warmed up in parallel with the first data fetch.

Tests

  • src/__tests__/dashboard-financial-overview.helpers.test.ts: 12 cases covering reference, undefined, empty, length, last-timestamp, and append-tail edge cases for the two new equality helpers.

Verification

Build (npm run build:dev):

Main bundle apexcharts chunk Loading strategy
Before 3 410 970 B 587 651 B (separate chunk 2019.*.chunk.js) static import - chunk fetched as soon as the dashboard module is parsed
After 3 410 870 B 587 651 B (same chunk) lazy() - chunk fetched only when a <Suspense> boundary actually renders

Total JS bytes across all chunks change by < 0.1 % (587 KB vs 587 KB for the chart vendor chunk, same content). The win is on the critical path, not raw bytes: screens that do not render a chart no longer pull in the apex bundle, and the overview prefetches it in parallel with getLatestBalance / getFinancialLog instead of blocking on a serial import.

Local CI gates (all green):

  • npm run lint
  • npm run test -> 295 tests, 27 suites, all passing (12 new)
  • npm run build:dev

Test plan

  • Open /dashboard/financial/overview, watch the network tab: the 2019.*.chunk.js (apex) request fires shortly after first paint, layout does not jump when the chart resolves.
  • Sit on the overview for >60s, watch React DevTools Profiler: the auto-refresh interval should produce no chart re-render when payload timestamps are unchanged (compare commit counts on the chart subtree before vs after).
  • Switch timeframes (1D / 1W / 1M etc.); both charts re-render correctly with the new data.
  • Navigate to /dashboard/financial/history after the overview - the apex chunk should already be in the browser cache (no extra request).
  • Reviewer: use the React DevTools Profiler to spot-check re-render counts for TotalBalanceLongChart / BalanceBarChart over a 5-minute window with no backend changes. Expectation: zero renders triggered by the polling refresh.

…hanged

The 60s auto-refresh in the financial overview always called setLatestBalance
and setLogEntries with fresh response objects, producing new array/object
references on every poll. That forced React to re-render the chart subtree
and triggered ApexCharts to redo SVG paints even when the payload was
identical.

Add two pure equality helpers and switch the setters to the functional form
so identity is preserved when nothing changed:
- sameLatestBalance compares the deterministic backend timestamp and the
  byType length as a cheap safety net.
- sameLogEntries leverages the append-only nature of the log and compares
  length plus the trailing timestamp.

Includes unit tests for both helpers covering reference, undefined, empty,
length, and tail-timestamp edge cases.
…puts

Two micro-optimizations that fix invalidated memos in the financial overview
render path:

1. useDashboard returned a useMemo'd object, but the five async functions
   were declared in the hook body and therefore recreated on every render.
   Move them inside the useMemo so their identity follows the call ref
   from useApi rather than every parent render.
2. SimpleBarChart computed categories, values and colors directly in the
   function body and then fed them as useMemo deps for options and series.
   New arrays each render meant the memos were effectively useless. Wrap
   the three arrays in a single useMemo keyed on data, so downstream memos
   actually short-circuit when the data reference is stable.
react-apexcharts pulls in apexcharts (~500KB minified) and was imported
synchronously by every dashboard chart component. Switch all five
components to React.lazy so the chunk is fetched on demand:
- total-balance-long-chart
- latest-balance-bar-chart (Simple + Stacked variants)
- balance-by-type-area-chart (Plus + Minus + Total variants)
- financial-changes-chart (Total + Plus + Minus variants)
- total-balance-short-chart (Plus + Minus + Total variants)

Each Chart usage is wrapped in a Suspense boundary with a fallback div
that matches the chart's height (250/300/400 px) so the layout does not
jump while the chunk is in flight.

The financial overview screen also fires a non-blocking prefetch for
react-apexcharts in a mount-time useEffect, so the chunk is warmed up
in parallel with the first data fetch and the chart paint is not stalled
on a separate network round-trip.
The prefetch fires off a dynamic import without awaiting the result. An
unhandled rejection (e.g. the user navigates away before the chunk
arrives) would surface as an uncaught promise warning. Attach a no-op
catch so the prefetch stays best-effort.
@TaprootFreak TaprootFreak marked this pull request as ready for review May 19, 2026 20:44
@TaprootFreak TaprootFreak requested a review from davidleomay as a code owner May 19, 2026 20:45
The inline `?? []` fallbacks created a fresh array reference on every render,
which invalidated the chart's internal useMemo caches every time. Wrap the
fallback in useMemo so the array identity stays stable while latestBalance is
unchanged, letting the chart's downstream memoization actually take effect.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant