Skip to content

fix(stop-detail): retain header across rotation to avoid refetch - #165

Closed
ai-tiro wants to merge 1 commit into
fix/160-nearby-permissionfrom
fix/161-stopdetail-rotation
Closed

fix(stop-detail): retain header across rotation to avoid refetch#165
ai-tiro wants to merge 1 commit into
fix/160-nearby-permissionfrom
fix/161-stopdetail-rotation

Conversation

@ai-tiro

@ai-tiro ai-tiro commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #161. Stacked on #164 (fix/160-nearby-permission) — review/merge that first; this PR's base is that branch, not master.

What I did

Two minimal guards in StopDetailViewModel:

  1. loadHeader() is now idempotent. It returns early when the header is already HeaderState.Loaded, so a ViewModel that re-enters init (or is recreated by the per-NavKey hiltViewModel keying) never re-hits stops/{id}/route_type/{rt}. The genuine first load still runs (header starts Loading), and retryHeader() resets the state to Loading before calling, so retry-after-error still re-fetches.
  2. A Result.Loading poll emission no longer wipes an already-Loaded departures list. On rotation, repeatOnLifecycle(RESUMED) re-subscribes the poll and the repository's first re-emission is Loading. Previously that flipped the list back to the loading skeleton — the visible reload flicker. Now the last-good rows stay on screen and the next Success tick replaces them in place. The genuine first load (no rows yet) still shows the skeleton.

Tests:

  • Added header is fetched exactly once on first load, restarting the departures poll does not refetch the header, loading emission after data keeps the last-good list on screen.
  • Reworked the old loading emission flips departures back to Loading test (which asserted the now-removed flicker behaviour) into first loading emission shows the loading skeleton (first-load skeleton still works).

What I discovered / diagnosis of WHY rotation refetched

The departures refetch is the clear, always-reproducible half: the poll is hot only while collected, and repeatOnLifecycle(RESUMED) cancels-and-resubscribes on every Pause→Resume — and a rotation is a Pause→Stop→Resume. The fresh subscribe forces an upstream fetch and emits Loading, which the ViewModel was mapping straight to DeparturesState.Loading, flashing the skeleton.

The header refetch only occurs if the ViewModel is recreated across the config change (e.g. the assisted-inject-per-NavKey path with no SavedState retention, per the Nav3-alpha caveat in CLAUDE.md). Rather than depend on SavedStateHandle — which the issue notes Nav3 alpha doesn't wire NavKey fields into — I made the fetch itself idempotent, which is correct whether the VM survives or is recreated with retained state, and is the smaller change.

What I tried that didn't work / chose not to do

Considered retaining the header via SavedStateHandle. Skipped it: Nav3 alpha doesn't populate the NavKey into saved state here, and the idempotent-loadHeader guard covers the reported behaviour with far less surface area and no new serialization concerns.

Justification / testing notes

Both changes are local to the ViewModel and preserve the documented contracts: the 30s polling cadence, pull-to-refresh (the spinner is its own feedback, list is untouched), background-pause, and the mid-poll error → next-success recovery path all behave as before. No one-way-door changes.

Test gap: there's no instrumented rotation test that actually rotates the device; the unit tests simulate the rotation effect (re-entering startObserving and the Loading re-emission) at the ViewModel seam. A faithful androidTest rotation test is heavier and was left out — the production fix is prioritised. :feature:stop-detail:compileDebugKotlin and :feature:stop-detail:testDebugUnitTest both pass.

🤖 Generated with Claude Code

Rotating the stop-detail screen fired two avoidable requests plus a reload
flicker: the header re-fetched `stops/{id}/route_type/{rt}` and the departures
poll restarted, flashing the loading skeleton.

Two minimal, idiomatic guards:

- `loadHeader()` is now idempotent — it returns early when the header is
  already `Loaded`, so a recreated/re-entered ViewModel never re-hits the stop
  header endpoint. The genuine first load still runs (header is `Loading`), and
  `retryHeader()` resets to `Loading` first so retry-after-error still works.
- A `Result.Loading` emission no longer wipes an already-`Loaded` departures
  list. On rotation `repeatOnLifecycle(RESUMED)` re-subscribes the poll and the
  repository's first re-emission is `Loading`; we keep the last-good rows on
  screen and let the next `Success` tick replace them in place. The genuine
  first load (no rows yet) still shows the skeleton.

The 30s cadence, pull-to-refresh, and background-pause behaviour are unchanged.

Co-Authored-By: ai-tiro <ai-tiro@jfx.ac>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Debug APK: app-debug-c64a9cdb036ec32dbcd1b6ee8c960cc1c8ac7a39.apk (built from c64a9cd at 2026-06-18 01:27 UTC)

Requires GitHub login. Artifact expires after 3 days.

@ai-tiro

ai-tiro commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #179, which combined these commits and has merged to master. Closing as superseded.

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.

2 participants