---
title: >-
  Data-views page can strand the table on a permanent loading spinner after a filter change shortly
  after load
type: task
status: planned
description: >-
  A signed-in user with a saved after-week receiving-statistics view on the /data-views route
  switched the team dropdown within the first moments of the page loading, and the table locked onto
  its loading spinner. No status text or banner was shown, the browser console stayed clean, the
  spinner did not go away by itself, a further filter change made minutes later had no effect, and a
  manual page reload was the sole recovery. The failure happened twice, both times on this one view
  and its team selector.
base_uri: user:task/league/contribution-intake/submission-ae737c6b-b3f3-4a26-adff-36e1e097de47.md
created_at: '2026-09-22T07:24:40.765Z'
entity_id: e37ef796-f1c2-4b28-ab03-4b7000473bda
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-10-24T23:54:08.193Z'
visibility_analyzed_at: '2026-11-05T23:48:37.472Z'
---

Investigate and fix a client-side race in the data-view results request lifecycle that can leave the data_view_request slice in an unsettled state (permanent spinner, no error UI) when an active filter is swapped within the first seconds of the route's initial paint.

## Background: the request lifecycle as the code defines it

Relevant wiring, all verified by reading the tree:
- A filter change dispatches DATA_VIEW_CHANGED; the `data_view_changed` saga in app/core/data-views/sagas.js calls `handle_data_view_request`, which first `send()`s the socket frame `DATA_VIEW_REQUEST` (`request_id` = view id) and only afterwards `put()`s the redux `DATA_VIEW_REQUEST` action that (a) flips `data_view_request.status` to "pending" and clears `result` (app/core/data-view-request/reducer.js), (b) flips the per-view `is_fetching` flag (app/core/data-views/reducer.js), and (c) starts the request watchdog via `takeLatest` (sagas.js `watch_data_view_request_watchdog`).
- The socket send is opt-in queued: `queue_until_open` + `replace_key 'data_view_request'` hold the frame in a client buffer until the socket opens, and a later request REPLACES an earlier queued one (app/core/ws/send-queue.js, app/core/ws/service.js). `openWS` and `closeWS` both `clear()` that buffer.
- The server (api/sockets/data_view.mjs) allows at most one live execution per socket: a newer request ABORTS the previous execution, and an aborted execution returns SILENTLY - `error.code === 'ABORTED'` sends no terminal frame. Results are delivered only for the newest execution; older ones are discarded with a telemetry record.
- The watchdog races a silence budget against progress frames and terminal frames. The budget tightens to 12s after the first heartbeat; the no-heartbeat budget is 6 minutes. On timeout with the socket CLOSED it exits with `if (!isOpen()) return` - no error frame - trusting `watch_websocket_reconnected`/`replay_in_flight_data_view_request` to re-issue the request. That replay is gated on a `WEBSOCKET_RECONNECTED` action and on the slice still being in "pending"/"processing".

So the client relies on exactly one live watchdog, tied by `takeLatest` to the newest DATA_VIEW_REQUEST action, plus one replay on reconnect, to guarantee that a "pending" slice always settles (or visibly errors). Any path that retires/hides that watchdog or drops the in-flight request without either producing a terminal frame or firing a reconnect leaves the UI on a bare spinner with no message - the symptom in the report.

## Reproduction target
Cold-load the /data-views route so the first view query is issued while the socket is still connecting (queued), then change the team dropdown to another team within a couple of seconds. Repeat until the table sticks on the spinner. The expectation to verify/debug with instrumentation: the wedge state has `data_view_request.status` still "pending", `result` empty, no terminal frame for the latest request, and no error UI.

Instrumentation to add first (a requirement, not optional): extend the request slice or the watchdog to record a sequential request counter and, when a "pending" request exceeds its silence budget, emit a telemetry/`log_data_view_telemetry`-style record and a client signal capturing { view id, whether heartbeats were ever seen, socket ready state at timeout, whether a reconnect ever fired, queued-vs-sent }. This turns the silent hang into something a follow-up reproduction can attribute.

## Suspected root-cause seams (all present in code; confirm which fires on reproduction)
1. Watchdog silent-walks away off-socket: `if (!isOpen()) return` discards the only client-side bound, and recovery is contingent on a `WEBSOCKET_RECONNECTED` that can simply never fire - e.g. a deliberate socket swap (`connect_auth`, which detaches `onclose`) while the request slice is "pending", or a reconnect loop that never reports in. Result: permanent "pending", bare spinner, no banner, no console error, reload-only recovery. This matches the report's absence of any status line.
2. Queued-then-cleared loss: a request enqueued during the connect window is wiped by `openWS`/`closeWS` `clear()`; if the slice was already "pending" for it and the replay does not run for the reason in (1), nothing re-drives it.
3. `takeLatest` watchdog desync: the watchdog tracks the newest DATA_VIEW_REQUEST action, but the server answers only the newest execution on the wire; any interleaving (e.g. the SET_SELECTED_DATA_VIEW chain and the DATA_VIEW_CHANGED chain both calling handle_data_view_request) can put a DATA_VIEW_REQUEST action for a request whose frame was superseded on the wire, stranding the slice in "pending".
4. Both `data_views` and `data_view_request` reducers key settle-frames by `request_id` == view id, so concurrent requests for one view cannot be told apart; a stale result clears state a newer request still needs. (Related: a throw inside the page's memoized listing/percentile selectors has previously wedged the same page under a "Request queued..." banner - fixed 2026-09-20; keep this class in mind but do not re-treat it.)

## Plan
Primary objective: make it impossible for a "pending" slice to outlive its watchdog without EITHER a terminal frame OR a surfaced, visible error with a retry action.
- [ ] Add the request-lifecycle instrumentation described under Reproduction target, then reproduce and confirm which seam fires.
- [ ] Replace the silent `!isOpen()` return in the watchdog with a bounded wait for re-open/replay, and if the re-open does not happen within the relevance window, emit the error frame (client_timeout) so the page shows the Retry UI instead of a bare spinner.
- [ ] Make the replay self-driving: re-issue the in-flight request on socket OPEN as well as WEBSOCKET_RECONNECTED, and re-check before the watchdog ever gives up, so a deliberate swap cannot orphan a "pending" request.
- [ ] Give each request a monotonic sequence at send time in the slice; have the watchdog and the settle reducers tolerate/retire only the latest sequence so a superseded request's terminal frame (or its absence) cannot clear or strand state for the request the user is actually looking at.
- [ ] Guard the queue-loss path: when `clear()` drops a queued request that the slice has already marked "pending", the self-driving replay above must rewrite the request from current selected-view state rather than leave it dropped.
- [ ] Add the specs listed under Tests.
- [ ] Run the manual and spec checks listed under Verification.

## Tests
- Saga-level (redux-saga store harness, matching the style of test/app.data-view-request-reducer.spec.mjs and test/websocket.send-queue.spec.mjs): rapid consecutive data_view_changed with an interleaved socket connect/fail/swap, asserting the slice always reaches "completed" or "error" within the (injected, shortened) silence budget and never sits in "pending" when no live watchdog can produce a frame.
- A test asserting that a request enqueued during the connect window is either flushed on open or replayed, never cleared with the slice left "pending".
- Watchdog tests for the three settle outcomes (finished, error, and the new bounded-wait-then-error path) with an injected clock.

## Verification
Manual: cold-load /data-views, change the team dropdown at least twice before the first result settles, confirm the table either renders the new rows or surfaces the error/retry UI every time; run the new specs plus the existing data-view request specs; confirm no new signals for swallowed or missed terminal frames on a fast-fire filter sequence.

## Provenance

Authored by a contribution intake session from submission `ae737c6b-b3f3-4a26-adff-36e1e097de47`. The report itself lives in league's `contribution_submissions` and is deliberately not reproduced here.
