Skip to main content

RSMS results prefetch and cancellation — design

This document records agreed design decisions before implementation. It covers background loading of scenario-scoped results APIs so top-bar views (especially Maps) are not contending with redundant or badly ordered work, without requiring ResultsPage to remount on every visit.

Problem

DashboardLayout keeps several top-bar views mounted at once (Maps, Street View, Hydraulics, Results, Barge Traffic), toggling visibility by route. ResultsPage therefore runs its data effects even when another tab is visible, which issues many results API calls as soon as basin + scenario are available.

Maps also calls results-related endpoints (e.g. plume river-mile axis for merged Ohio GeoJSON). That can duplicate network work and saturate the browser’s connection pool, so the first interaction with Maps feels slow even though the map component itself does not “await” Results in code.

Goals

  • Prefetch scenario-scoped results metadata (and optionally heavier payloads) as soon as a scenario is reasonably knowable — e.g. when the user selects a scenario on Scenarios, not only after opening Maps or Results.
  • Cancel or ignore work when the user switches scenario (or basin) before requests complete; never commit stale data to UI state.
  • Avoid duplicate fetches across consumers (Results, Maps plume merge, prefetch runner) via a shared cache keyed by (riverbasin_id, scenario_id).
  • Do not rely on “mount ResultsPage only when /dashboard/results is active” as the primary strategy, so in-page state (sliders, toggles, last selections) survives tab switches without a full cold reload.

Non-goals

  • Changing backend shapes or adding new endpoints (unless later discovered necessary).
  • Mandatory service workers or offline persistence.
  • Replacing the stacked top-bar layout entirely (only data-loading behavior is in scope).

Context (current architecture)

  • Global selection: selectedRiverbasinId, selectedScenario in GlobalProvider / useSelectedRiverbasin().
  • Results API access: rsms-frontend-react/src/api/results.js and get() in rsms-frontend-react/src/api/client.js (GET only in initial scope).
  • Map merge hook: useMergedOhioMapData fetches static GeoJSON plus getPlumeRiverMileAxis when basin + scenario exist.

Finalized design decisions

1. Prefetch start trigger

Prefetch may start as soon as selectedRiverbasinId and selectedScenario.id are set — for example immediately after selection on the Scenarios page. Nothing requires a top-bar route to mount first.

Constraints:

  • Prefetch must respect authentication the same way existing calls do (no token races).
  • Prefetch should be non-blocking by default: schedule work asynchronously (e.g. after the current task, or requestIdleCallback where appropriate) so selection UI stays responsive.

2. Keep ResultsPage mounted; integrate with cache instead of unmounting

We will not adopt “render ResultsPage only when the Results tab is active” as the main fix. Subsequent visits to Results should reuse warm state where possible.

Because the page stays mounted while other tabs are visible, we must still coordinate:

  • Prefer shared cache / in-flight promises so Results effects do not blindly refetch the same resources the prefetch just completed.
  • Optionally defer heavier series loads until Results is foregrounded or idle, if profiling shows hidden-tab work still hurts Maps (policy layer on top of cache, not mandatory for v1).

3. Shared cache and deduplication

Introduce a small client-side results cache (module or context):

  • Key: stable string from (riverbasinId, scenarioId) (and optionally a resource name: e.g. plumeRiverMileAxis, cxpltHoursDomain, …).
  • Value: resolved JSON (or error sentinel) plus optional in-flight Promise deduplication so concurrent callers share one network request.
  • Eviction: bounded size (e.g. LRU for last N scenarios) to cap memory; exact N chosen at implementation time.

Maps: useMergedOhioMapData should use getOrFetch for plume river-mile axis (or equivalent) so Results and Maps do not duplicate that GET for the same scenario.

4. Cancellation and stale-response safety

Two layers (both recommended for v1):

  1. Logical generation / version: increment a monotonic counter (or derive from scenarioId + sequential token) whenever basin or scenario changes. Every async completion checks “still current?” before calling setState or writing cache entries for that scenario.

  2. AbortSignal: extend request/get in api/client.js to forward an optional signal to fetch. The prefetch orchestrator passes a controller that is abort() when the user selects a different scenario (or clears selection).

Note: aborted fetches reject; callers must distinguish abort from real errors so UI does not flash misleading error toasts.

5. HTTP GET scope

Initial implementation targets GET results endpoints only. Other verbs can adopt signal later for consistency.

Suggested implementation phases (ordered)

  1. Cache + deduping get layer for results resources used by Maps and Results (plume axis first, then domain endpoints as needed).
  2. AbortSignal support in client.js / results.js, wired into prefetch and Results loaders.
  3. Prefetch orchestrator (provider or hook near GlobalProvider / dashboard root): on scenario selection, enqueue domain/light calls first, then heavier payloads; honor generation + abort.
  4. Refactor ResultsPage (and useMergedOhioMapData) to read-through cache and avoid duplicate parallel fetches with prefetch.

Risks and mitigations

RiskMitigation
Hidden ResultsPage still fires effects and doubles bandwidthCache + shared in-flight promises; optional deferral for heavy series when tab hidden
Fast scenario switching shows wrong data brieflyGeneration check before every state update; clear per-scenario loading flags
Memory growth from caching large payloadsLRU cap; cache metadata first if full series too large
Abort treated as user-visible errorCatch AbortError / DOMException name and skip error UI

Open items

Resolved for v1: prefetch includes domains + CX at hour 0 + CT at mile 0 + edge resolve + mass; LRU holds two scenarios storing full JSON per keyed response. Tab-visibility deferral remains optional — see rsms-master-tasklist if profiling shows it is worthwhile.

Implementation (v1)

  • src/utils/resultsScenarioBundleCache.js — LRU capacity 2 (RESULTS_SCENARIO_CACHE_CAPACITY). Stores full parsed JSON per resource key on a scenario bundle: CX/CT domains, plume river-mile axis, cxpltByHour (per hour cache key), ctpltByMile (per mile key), resolved edge envelope, mass balance. In-flight dedupe via pendingField.
  • PREFETCH_INITIAL_CX_HOUR = 0, PREFETCH_INITIAL_CT_MILES = 0 — first slider steps for prefetch (aligned with cxpltDomainAllowsHourZero / ctpltDomainAllowsMilesZero on ResultsPage when setting defaults).
  • src/components/ResultsScenarioPrefetch.jsx — mounted in App.jsx under SelectedRiverbasinProvider; starts prefetchScenarioResultsBundle when authenticated and basin + scenario are set; AbortController aborts prefetch when selection changes or component unmounts.
  • get() / fetch in api/client.js — optional signal passed through; results.js GET helpers accept { signal }.
  • ResultsPage.jsx, useMergedOhioMapData.js, RsmsStationCtpltPanel.jsx — results reads go through cachedGet* helpers so Maps and prefetch share cached bytes.

Stale writes after LRU eviction during an in-flight request are benign: getOrPopulateField assigns into the bundle only if the Map entry still exists for that key.

References (code)

  • rsms-frontend-react/src/layouts/dashboard/index.jsx — stacked top-bar views.
  • rsms-frontend-react/src/pages/ResultsPage.jsx — scenario-scoped results effects.
  • rsms-frontend-react/src/pages/map/useMergedOhioMapData.js — plume axis for map layer.
  • rsms-frontend-react/src/api/results.js, rsms-frontend-react/src/api/client.js — API access.
  • rsms-frontend-react/src/utils/resultsScenarioBundleCache.js, rsms-frontend-react/src/components/ResultsScenarioPrefetch.jsx — LRU cache + background prefetch.