| 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-16T00:00:00.000Z" |
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:
- A
StreamProvider that watches router.routerDelegate for location changes
- This stream parses routes into a
RouteContext with a type field (home, explore, etc.)
- Downstream providers gate on
routeContext.type == RouteType.home and return
AsyncValue.loading() when the type doesn't match
- 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
// 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
// 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:
- Login → redirect to home → home feed loads immediately (no permanent spinner)
- No
RAPID REBUILD warnings (this fix doesn't cause those)
- Swipe through feed works normally
- Pull-to-refresh works
- Navigate away and back — feed still loads
- Test with
ref.watch(homeFeedProvider) in initState to confirm data arrives
Example: Complete Fix (HomeScreenRouter)
Before (stuck on loading):
@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):
@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