Main coves client
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(post): instant scroll restoration when returning to cached post

Move provider initialization from postFrameCallback to didChangeDependencies
for synchronous access before first build. Create ScrollController with
initialScrollOffset set to cached position, eliminating the visible flash
from loading → content at top → jump to cached position.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+51 -44
+51 -44
lib/screens/home/post_detail_screen.dart
··· 37 /// - Loading, empty, and error states 38 /// - Automatic comment loading on screen init 39 class PostDetailScreen extends StatefulWidget { 40 - const PostDetailScreen({required this.post, this.isOptimistic = false, super.key}); 41 42 /// Post to display (passed via route extras) 43 final FeedViewPost post; ··· 51 } 52 53 class _PostDetailScreenState extends State<PostDetailScreen> { 54 - final ScrollController _scrollController = ScrollController(); 55 final GlobalKey _commentsHeaderKey = GlobalKey(); 56 57 // Cached provider from CommentsProviderCache ··· 67 @override 68 void initState() { 69 super.initState(); 70 - _scrollController.addListener(_onScroll); 71 72 - // Initialize provider after frame is built 73 - WidgetsBinding.instance.addPostFrameCallback((_) { 74 - if (mounted) { 75 - _initializeProvider(); 76 - _setupAuthListener(); 77 - } 78 - }); 79 } 80 81 /// Listen for auth state changes to handle sign-out ··· 92 93 // If user signed out while viewing this screen, navigate back 94 // The CommentsProviderCache has already disposed our provider 95 - if (!authProvider.isAuthenticated && _isInitialized && !_providerInvalidated) { 96 _providerInvalidated = true; 97 98 if (kDebugMode) { ··· 113 } 114 } 115 116 - /// Initialize provider from cache and restore state 117 - void _initializeProvider() { 118 // Get or create provider from cache 119 final cache = context.read<CommentsProviderCache>(); 120 _commentsCache = cache; ··· 123 postCid: widget.post.post.cid, 124 ); 125 126 // Listen for changes to trigger rebuilds 127 _commentsProvider.addListener(_onProviderChanged); 128 129 // Skip loading for optimistic posts (just created, not yet indexed) 130 if (widget.isOptimistic) { 131 if (kDebugMode) { ··· 133 } 134 // Don't load comments - there won't be any yet 135 } else if (_commentsProvider.comments.isNotEmpty) { 136 - // Already have data - restore scroll position immediately 137 if (kDebugMode) { 138 debugPrint( 139 '📦 Using cached comments (${_commentsProvider.comments.length})', 140 ); 141 } 142 - _restoreScrollPosition(); 143 144 - // Background refresh if data is stale 145 if (_commentsProvider.isStale) { 146 if (kDebugMode) { 147 debugPrint('🔄 Data stale, refreshing in background'); ··· 152 // No cached data - load fresh 153 _commentsProvider.loadComments(refresh: true); 154 } 155 - 156 - setState(() { 157 - _isInitialized = true; 158 - }); 159 } 160 161 @override ··· 194 if (mounted) { 195 setState(() {}); 196 } 197 - } 198 - 199 - /// Restore scroll position from provider 200 - void _restoreScrollPosition() { 201 - final savedPosition = _commentsProvider.scrollPosition; 202 - if (savedPosition <= 0) { 203 - return; 204 - } 205 - 206 - WidgetsBinding.instance.addPostFrameCallback((_) { 207 - if (!mounted || !_scrollController.hasClients) { 208 - return; 209 - } 210 - 211 - final maxExtent = _scrollController.position.maxScrollExtent; 212 - final targetPosition = savedPosition.clamp(0.0, maxExtent); 213 - 214 - if (targetPosition > 0) { 215 - _scrollController.jumpTo(targetPosition); 216 - if (kDebugMode) { 217 - debugPrint('📍 Restored scroll to $targetPosition (max: $maxExtent)'); 218 - } 219 - } 220 - }); 221 } 222 223 /// Handle sort changes from dropdown
··· 37 /// - Loading, empty, and error states 38 /// - Automatic comment loading on screen init 39 class PostDetailScreen extends StatefulWidget { 40 + const PostDetailScreen({ 41 + required this.post, 42 + this.isOptimistic = false, 43 + super.key, 44 + }); 45 46 /// Post to display (passed via route extras) 47 final FeedViewPost post; ··· 55 } 56 57 class _PostDetailScreenState extends State<PostDetailScreen> { 58 + // ScrollController created lazily with cached scroll position for instant restoration 59 + late ScrollController _scrollController; 60 final GlobalKey _commentsHeaderKey = GlobalKey(); 61 62 // Cached provider from CommentsProviderCache ··· 72 @override 73 void initState() { 74 super.initState(); 75 + // ScrollController and provider initialization moved to didChangeDependencies 76 + // where we have access to context for synchronous provider acquisition 77 + } 78 79 + @override 80 + void didChangeDependencies() { 81 + super.didChangeDependencies(); 82 + // Initialize provider synchronously on first call (has context access) 83 + // This ensures cached data is available for the first build, avoiding 84 + // the flash from loading state → content → scroll position jump 85 + if (!_isInitialized) { 86 + _initializeProviderSync(); 87 + } 88 } 89 90 /// Listen for auth state changes to handle sign-out ··· 101 102 // If user signed out while viewing this screen, navigate back 103 // The CommentsProviderCache has already disposed our provider 104 + if (!authProvider.isAuthenticated && 105 + _isInitialized && 106 + !_providerInvalidated) { 107 _providerInvalidated = true; 108 109 if (kDebugMode) { ··· 124 } 125 } 126 127 + /// Initialize provider synchronously from cache 128 + /// 129 + /// Called from didChangeDependencies to ensure cached data is available 130 + /// for the first build. Creates ScrollController with initialScrollOffset 131 + /// set to cached position for instant scroll restoration without flicker. 132 + void _initializeProviderSync() { 133 // Get or create provider from cache 134 final cache = context.read<CommentsProviderCache>(); 135 _commentsCache = cache; ··· 138 postCid: widget.post.post.cid, 139 ); 140 141 + // Create scroll controller with cached position for instant restoration 142 + // This avoids the flash: loading → content at top → jump to cached position 143 + final cachedScrollPosition = _commentsProvider.scrollPosition; 144 + _scrollController = ScrollController( 145 + initialScrollOffset: cachedScrollPosition, 146 + ); 147 + _scrollController.addListener(_onScroll); 148 + 149 + if (kDebugMode && cachedScrollPosition > 0) { 150 + debugPrint( 151 + '📍 Created ScrollController with initial offset: $cachedScrollPosition', 152 + ); 153 + } 154 + 155 // Listen for changes to trigger rebuilds 156 _commentsProvider.addListener(_onProviderChanged); 157 158 + // Setup auth listener 159 + _setupAuthListener(); 160 + 161 + // Mark as initialized before triggering any loads 162 + // This ensures the first build shows content (not loading) when cached 163 + _isInitialized = true; 164 + 165 // Skip loading for optimistic posts (just created, not yet indexed) 166 if (widget.isOptimistic) { 167 if (kDebugMode) { ··· 169 } 170 // Don't load comments - there won't be any yet 171 } else if (_commentsProvider.comments.isNotEmpty) { 172 + // Already have cached data - it will render immediately 173 if (kDebugMode) { 174 debugPrint( 175 '📦 Using cached comments (${_commentsProvider.comments.length})', 176 ); 177 } 178 179 + // Background refresh if data is stale (won't cause flicker) 180 if (_commentsProvider.isStale) { 181 if (kDebugMode) { 182 debugPrint('🔄 Data stale, refreshing in background'); ··· 187 // No cached data - load fresh 188 _commentsProvider.loadComments(refresh: true); 189 } 190 } 191 192 @override ··· 225 if (mounted) { 226 setState(() {}); 227 } 228 } 229 230 /// Handle sort changes from dropdown