本文へ移動
cccskills
無料GitHub で公開

riverpod-stream-provider-auth-transition-oscillation

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.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md7.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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

// 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:

  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):

@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

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Fix ArgoCD ExternalSecret deployment failing with "namespace X is not permitted in project Y". Use when: (1) ExternalSecret shows OutOfSync in ArgoCD but won't sync, (2) ArgoCD application status shows "namespace X is not permitted in project 'infrastructure'", (3) ExternalSecret targets a namespace managed by a different ArgoCD project, (4) Using apps-of-apps pattern with separate infrastructure and application projects.

日本語の概要は準備中です。原文の説明を表示しています。

divinevideo/divine-mobile2662026年10月13日 更新

Art direction for any content — reads text, PDF, Word, HTML, PPT, then proposes 2-3 creative directions with photography style, mood, and visual language. After selection, generates AI image prompts and visual briefs section-by-section. Use when the user shares content and needs visual direction, image sourcing, or creative direction for any material.

日本語の概要は準備中です。原文の説明を表示しています。

divinevideo/divine-mobile2662026年10月13日 更新

Fix "Null check operator used on a null value" errors when an object is set to null during an async await. Use when: (1) Object reference is nullified while awaiting, (2) Code accesses object with ! after await returns, (3) Cancel/dispose operations run concurrently with async operations on same object. Solution: capture local reference before await.

日本語の概要は準備中です。原文の説明を表示しています。

divinevideo/divine-mobile2662026年10月13日 更新

Add custom metadata headers (x-amz-meta-*) to AWS v4 signed requests for GCS S3-compatible API. Use when: (1) Adding custom metadata to GCS uploads via S3 API, (2) Getting signature mismatch errors after adding new headers, (3) x-amz-meta-* headers being ignored or causing 403 errors. Custom headers MUST be included in canonical headers and signed headers list.

日本語の概要は準備中です。原文の説明を表示しています。

divinevideo/divine-mobile2662026年10月13日 更新

Fix password/secret authentication failures caused by trailing newlines when creating Google Cloud secrets (or similar) with bash here-strings. Use when: (1) Password authentication fails with correct password, (2) Secret created with `<<< "value"` syntax, (3) Error like "password authentication failed" or "invalid token" despite correct value. Bash here-strings (`<<<`) add a trailing newline that corrupts secrets.

日本語の概要は準備中です。原文の説明を表示しています。

divinevideo/divine-mobile2662026年10月13日 更新

Fix silent video/media processing failures caused by URL extraction code that filters on file extensions (.mp4, .webm, .webp). Use when: (1) Media moderation, transcoding, or analysis silently skips files from Blossom or content-addressed storage servers, (2) URL extraction from Nostr event tags (imeta, r tags) drops URLs without recognized extensions, (3) CDN fallback URLs append .mp4 but the actual server uses extensionless content-addressed paths like /{sha256}. Common in Nostr video events (kind 34236) where different clients use different URL formats.

日本語の概要は準備中です。原文の説明を表示しています。

divinevideo/divine-mobile2662026年10月13日 更新

divinevideo のスキルをすべて見る

このスキルの問題を報告する