Skip to content

fix(#1360): analytics/health-chart integrity - invariants, retry, ded… - #1388

Merged
1nonlypiece merged 2 commits into
StellarLend:mainfrom
Gabugo-tech:fix/1360-analytics-chart-integrity-invariants
Aug 30, 2026
Merged

1nonlypiece merged 2 commits into
StellarLend:mainfrom
Gabugo-tech:fix/1360-analytics-chart-integrity-invariants

Conversation

@Gabugo-tech

Copy link
Copy Markdown
Contributor

[#1360] fix: analytics/health-metric chart integrity — transactional invariants and recovery
Summary
This PR addresses production-quality risks in the analytics and health-metric chart implementation anchored at components/features/dashboard by making all state transitions deterministic, atomic, and recoverable across retries, refreshes, and interrupted wallet operations.

Problem
The existing chart components (SupplyApyChart, CollateralRatioHistoryChart) and the usePositionHistory hook had several integrity gaps:

State was updated across multiple setState calls, allowing torn intermediate renders (e.g. status="ready" with empty data)
Both chart components independently fetched the same /api/positions/history endpoint with no deduplication — two HTTP requests fired every time PositionSummary mounted
A single fetch failure immediately surfaced an error with no retry, losing all chart data
Stale responses from superseded fetches (window changes, rapid remounts) could overwrite newer data
effectiveSupplyApy values were rendered without bounds — a server returning 150% or NaN would be passed directly to the chart
Snapshots with negative, non-finite, or zero-timestamp values were not excluded before computing derived metrics (collateral ratio, net worth)
No stale-data signal was propagated to usePositionHistory consumers — NetWorthTrend had no way to know data was being retried
Changes

useChartHistory.ts
(new)
Shared fetch layer for SupplyApyChart and CollateralRatioHistoryChart. Enforces six explicit invariants:

Atomic state transitions — useReducer with a discriminated-union ChartHistoryState (idle | loading | loading-stale | ready | empty | error). A single dispatch replaces all setState combos; no component ever sees a half-updated state.
Request deduplication — module-level inflightRequests: Map<url, Promise>. Two concurrent mounts for the same URL share one HTTP request.
Stale-response rejection — monotonically-increasing generationRef counter. Responses from superseded fetch sequences are silently discarded.
Exponential back-off retry — up to 3 retries, base 800 ms, capped at 8 s, with ±300 ms jitter. Back-off sleeps are abort-safe (cancelled on unmount or URL change).
Stale data preservation — lastGoodSnapshotsRef carries the last successful payload into loading-stale and error states so charts never go blank during retries.
Unit normalisation — supplied/borrowed must be finite and non-negative; effectiveSupplyApy clamped to [0, 100] (NaN/Infinity → 0); invalid timestamps rejected; collateralRatio is null when borrowed === 0.

SupplyApyChart.tsx
Removed internal useState/useEffect fetch loop; now consumes useChartHistory
Added fetcher prop for test injection
Renders amber stale-data advisory banner + thin progress bar when retrying with prior data visible
Hard-error state only shown when there is no stale fallback
APY lower-bound floored at 0 (respects the [0, 100] normalisation invariant)

CollateralRatioHistoryChart.tsx
Same pattern as above
toRatioPoints() now derives from NormalizedSnapshot.collateralRatio (pre-validated, never null when borrowed > 0)
Stale advisory banner + progress bar during retries

usePositionHistory.ts
Added isStale to the return type
Added generation counter — responses from prior sequences discarded
normalizeSnapshot() validates finite non-negative supplied/borrowed, rejects invalid timestamps; incomparable data points never enter trend charts
Exponential back-off retry (max 3, same constants as useChartHistory)
isStale = true surfaces during retries; cleared atomically with new data on recovery
refetch() signature changed from () => Promise to () => void (abort-safe, non-blocking)

NetWorthTrend.tsx
Consumes new isStale field
Renders amber advisory banner while retries are in flight
Acceptance criteria
Criterion How it is met
Defines and enforces invariants for normal and adversarial inputs normalizeSnapshot in both hooks; useReducer discriminated union; APY clamp; timestamp guard
State machine covers every success, rejection, cancellation, and retry path ChartHistoryState union: idle → loading → ready/empty/error; loading-stale for retry-with-data; generation counter for cancellation
Prevents duplicate submissions and stale responses from creating contradictory state inflightRequests Map deduplication; generation counter rejects out-of-order responses
Failure recovery preserves user intent without silently repeating an on-chain action lastGoodSnapshotsRef serves stale data during retries; max-retry cap prevents infinite silent loops; advisory banner informs user
Automated tests cover success, failure, boundary, retry, and permission behaviour See test section below
Tests

useChartHistory.test.ts
— 33 cases

Atomic state (never tears between loading/ready/error)
Deduplication: two concurrent mounts → 1 HTTP request
APY clamping to 100 for values >100; NaN and negative APY → 0
Exclusion of snapshots with negative supplied, non-finite borrowed, invalid timestamps
collateralRatio is null when borrowed === 0
Output sorted by timestamp ascending regardless of server order
Retry exhaustion: 1 initial + 3 retries = 4 calls, then error state
Recovery: retry success clears isStale atomically
Stale data preserved in error state after exhaustion
loading-stale surfaced while retrying with prior data
Stale-response rejection: slow first response discarded after refetch supersedes it
Abort on unmount
isChartLoading and getSnapshots selector correctness

usePositionHistory.test.ts
— 24 cases

Initial state, success, netWorth = supplied − borrowed, negative net worth allowed
Sort, boundary values (negative/NaN/Infinity supplied/borrowed, zero timestamps)
Retry: 4 calls total, isStale during retries, cleared on recovery
Generation counter: stale first response discarded after refetch
Abort on unmount
Window change triggers new fetch sequence

SupplyApyChart.test.tsx
— updated

fetcher prop pattern replaces global fetch stub
New cases: APY >100 displays as 100.00%; all-invalid snapshots → empty state; two-point trend summary text; stale advisory banner

CollateralRatioHistoryChart.test.tsx
— updated

fetcher prop pattern
New cases: stale advisory during retry; custom liquidation threshold; supplied=0 snapshot excluded
Design tradeoffs
useChartHistory vs extending each chart's hook independently — the shared hook eliminates the duplicate /api/positions/history request that fired on every PositionSummary mount. The tradeoff is a slightly higher abstraction layer that both chart components depend on.
Module-level inflightRequests Map — same pattern already used by usePrices. Cache is cleared when the request settles, so a stale entry is never served across page navigations.
Max 3 retries — aggressive enough to recover from transient failures without blocking the UI for more than ~10 s under worst-case back-off.
isStale kept true after retry exhaustion — intentional: tells consumers that displayed data may be outdated even after the error state is reached.
Limitations
The test suite requires npm install before running. The vitest multi-project config includes a Storybook browser project that requires Playwright; running targeted tests needs --project accessibility or --project server-unit to avoid waiting on browser setup.
useChartHistory deduplication is scoped to the current page lifecycle (module-level Map). It does not survive full page navigations, which is consistent with usePrices and appropriate for this data type.

closes #1360

Gabugo-tech and others added 2 commits August 29, 2026 17:13
… retry, dedup, stale recovery

- Add useChartHistory hook: atomic state (useReducer discriminated union),
  module-level request deduplication, generation counter for stale-response
  rejection, exponential back-off retry (max 3, jitter), unit normalisation
  (finite non-negative supplied/borrowed, APY clamped [0,100]), collateral
  ratio null when borrowed=0, stale data preserved in loading-stale/error states

- Refactor SupplyApyChart: consume useChartHistory, add fetcher prop for
  testability, stale advisory banner + progress bar during retries, APY
  lower-bound floored at 0, hard-error only when no stale fallback available

- Refactor CollateralRatioHistoryChart: consume useChartHistory, add fetcher
  prop, stale advisory banner + progress bar, ratio derived from validated
  NormalizedSnapshot.collateralRatio

- Upgrade usePositionHistory: add isStale field, generation counter, normalise
  snapshots (finite non-negative values, bad timestamps rejected), exponential
  back-off retry (max 3), abort-safe back-off wait, refetch() is now void

- Update NetWorthTrend: consume isStale, render amber advisory banner

- Add tests: useChartHistory.test.ts (atomic transitions, dedup, normalisation,
  APY clamping, retry exhaustion, stale-response rejection, abort-on-unmount,
  selectors), usePositionHistory.test.ts (success, netWorth, sort, boundary
  values, retry, isStale, generation counter, abort, window change),
  updated SupplyApyChart/CollateralRatioHistoryChart tests with fetcher prop
  pattern and new stale/retry/normalisation cases

Refs StellarLend#1360
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.

[Quality][High] Improve analytics and health-metric chart integrity: transactional invariants and recovery

2 participants