Skip to content
Back to skills

Riverpod Stream Provider Auth Transition Oscillation

ASecurity

Fix Flutter home feed / main screen stuck on loading spinner after login when using Riverpod StreamProvider that watches GoRouter location changes. Use when: (1) Screen shows BrandedLoadingIndicator or CircularProgressIndicator permanently after successful auth redirect, (2) Widget watches a route-type-gating provider that returns AsyncValue.loading() intermittently, (3) Logs show route location oscillating between stale and current paths during post-login transition (e.g., /welcome/* after /...

  • 265 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added May 27, 2026
developmentgoshellreactapi

Works with

  • claude code
  • api

Security analysis

A100/100

Scanned May 27, 2026

npx -y skills add divinevideo/divine-mobile --skill riverpod-stream-provider-auth-transition-oscillation --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Riverpod Stream Provider Auth Transition Oscillation?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Riverpod Stream Provider Auth Transition Oscillation
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/divinevideo-riverpod-stream-provider-auth-transition-oscillati/badge)](https://www.skillsdirectory.com/skills/divinevideo-riverpod-stream-provider-auth-transition-oscillati)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: riverpod-stream-provider-auth-transition-oscillation
description: |
  Fix Flutter home feed / main screen stuck on loading spinner after login when using
  Riverpod StreamProvider that watches GoRouter location changes. Use when: (1) Screen
  shows BrandedLoadingIndicator or CircularProgressIndicator permanently after successful
  auth redirect, (2) Widget watches a route-type-gating provider that returns
  AsyncValue.loading() intermittently, (3) Logs show route location oscillating between
  stale and current paths during post-login transition (e.g., /welcome/* after /home/0),
  (4) Provider chain has double-gate: widget gates on pageContext AND data provider also
  gates on pageContext. Distinct from riverpod-infinite-rebuild-loop (rapid rebuilds) —
  this causes permanent loading state, not infinite rebuilds.
author: Claude Code
version: 1.0.0
date: 2026-02-16
---

# Riverpod StreamProvider Auth Transition Oscillation

## Problem

After successful login and redirect, the main screen (home feed, dashboard, etc.) is
permanently stuck on a loading indicator. The auth redirect works correctly (URL shows
`/home/0`), but the screen never renders data. This is NOT a rapid rebuild issue — the
widget builds a few times then settles on a loading state.

## Context / Trigger Conditions

**Symptoms:**
- Screen stuck on loading spinner after successful login redirect
- URL bar / GoRouter shows correct path (e.g., `/home/0`)
- Data provider (e.g., `homeFeedProvider`) has data if checked directly
- No error messages — just permanent loading
- May show brief flash of content before reverting to loading

**Architecture that triggers this:**
1. A `StreamProvider` that watches `router.routerDelegate` for location changes
2. This stream parses routes into a `RouteContext` with a `type` field (home, explore, etc.)
3. Downstream providers gate on `routeContext.type == RouteType.home` and return
   `AsyncValue.loading()` when the type doesn't match
4. The widget watches the downstream provider and shows loading indicator

**The oscillation pattern:**
```
Auth state changes → Router redirects to /home/0
  → routerDelegate emits /home/0 ✓
  → routerDelegate emits /welcome/login (stale!) ✗
  → routerDelegate emits /home/0 ✓
  → routerDelegate emits /welcome/* (stale!) ✗
  ...oscillates for several frames
```

**Why it happens:**
GoRouter's `routerDelegate` listener fires for EVERY location change during transitions,
including intermediate/stale states. During post-login, the router processes multiple
pending navigations (pop welcome screen, push home screen) and the delegate emits each
intermediate state. A sync `StreamController` propagates these instantly.

**Log signature:**
```
CTX derive: type=RouteType.home npub=null index=0
CTX derive: type=RouteType.welcome npub=null index=null   ← stale!
CTX derive: type=RouteType.home npub=null index=0
```

## Root Cause Analysis

The issue is a **double gate** on an oscillating stream:

```
routerDelegate listener
  ↓ (emits every location change)
StreamProvider<RouteContext>  ← oscillates between /home and /welcome
  ↓
videosForHomeRouteProvider   ← returns loading() when type != home  [GATE 1]
  ↓
HomeScreenRouter.build()     ← watches pageContext for type check   [GATE 2]
```

When the stream oscillates, both gates open and close rapidly. The widget ends up
rendering the loading state from whichever emission came last in the settling period.

## Solution

### Pattern: "I Know Who I Am" — Bypass Route-Type Gating

When a widget **knows its own context** (it's only mounted at a specific route), it
doesn't need to gate on a route-type stream. It can read route info synchronously.

### Step 1: Read URL index synchronously from GoRouter

```dart
// BEFORE: Watching oscillating stream
final pageContext = ref.watch(pageContextProvider);
return pageContext.when(
  data: (ctx) {
    if (ctx.type != RouteType.home) return loading();
    // ...
  },
  loading: () => loading(),
  error: (e, s) => error(),
);

// AFTER: Read synchronously — this widget IS the home screen
final router = ref.read(goRouterProvider);
final location = router.routeInformationProvider.value.uri.toString();
final segments = location.split('/').where((s) => s.isNotEmpty).toList();
int urlIndex = 0;
if (segments.length > 1 && segments[0] == 'home') {
  urlIndex = int.tryParse(segments[1]) ?? 0;
}
```

### Step 2: Watch the data provider directly

```dart
// BEFORE: Watching intermediate provider that gates on route type
final videosAsync = ref.watch(videosForHomeRouteProvider);

// AFTER: Watch the data provider directly — no route-type gate needed
final videosAsync = ref.watch(homeFeedProvider);
```

### Step 3: Remove unused intermediate provider imports

Clean up imports for any intermediate route-gating providers that are no longer used.

## When NOT to Apply This Fix

- When the widget genuinely needs to render different content based on route type
  (e.g., a shared shell that shows different feeds)
- When the widget is mounted at multiple routes and needs to switch behavior
- If the issue is actually rapid rebuilds (use `riverpod-infinite-rebuild-loop` instead)

## Verification

After the fix:
1. Login → redirect to home → home feed loads immediately (no permanent spinner)
2. No `RAPID REBUILD` warnings (this fix doesn't cause those)
3. Swipe through feed works normally
4. Pull-to-refresh works
5. Navigate away and back — feed still loads
6. Test with `ref.watch(homeFeedProvider)` in initState to confirm data arrives

## Example: Complete Fix (HomeScreenRouter)

**Before (stuck on loading):**
```dart
@override
Widget build(BuildContext context) {
  final pageContext = ref.watch(pageContextProvider);
  return buildAsyncUI(
    pageContext,
    onData: (ctx) {
      if (ctx.type != RouteType.home) {
        return const Center(child: BrandedLoadingIndicator(size: 80));
      }
      final videosAsync = ref.watch(videosForHomeRouteProvider);
      return buildAsyncUI(videosAsync, ...);
    },
  );
}
```

**After (loads correctly):**
```dart
@override
Widget build(BuildContext context) {
  // Read URL synchronously — HomeScreenRouter is only at /home/:index
  final router = ref.read(goRouterProvider);
  final location = router.routeInformationProvider.value.uri.toString();
  final segments = location.split('/').where((s) => s.isNotEmpty).toList();
  int urlIndex = 0;
  if (segments.length > 1 && segments[0] == 'home') {
    urlIndex = int.tryParse(segments[1]) ?? 0;
  }

  // Watch data directly — no route-type gate needed
  final videosAsync = ref.watch(homeFeedProvider);
  return buildAsyncUI(videosAsync, onData: (state) { ... });
}
```

## Notes

- **Distinct from rebuild loops:** `riverpod-infinite-rebuild-loop` covers rapid rebuilds
  (50+ per second). This issue causes 3-8 rebuilds that settle on a LOADING state.
- **Related to auth timing:** Often co-occurs with synchronous router redirect needing
  data that isn't yet available. See companion fix: pre-fetch data before setting auth
  state so redirects have what they need.
- **StreamProvider vs read:** The oscillation only affects `StreamProvider` watching
  reactive router state. `ref.read()` of GoRouter's current location is stable.
- **GoRouter's routerDelegate:** This listener fires for every intermediate navigation
  state. It's reliable for final states but oscillates during multi-step transitions
  (login → pop welcome → push home).
- **Debug technique:** Add `print('CTX derive: type=${ctx.type}')` to the StreamProvider
  to see the oscillation pattern.

## Related Skills

- `riverpod-infinite-rebuild-loop` — rapid rebuilds from watch/listener issues
- `flutter-pageview-url-routing-reorder-loop` — infinite loop from item reorder tracking
- `flutter-startup-network-blocking` — blocking network ops during startup

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…