diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 12a6b980934..4349a79bd40 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 17.0.1 + +- Fixes an issue where `GoRouter.refresh()` did not trigger a widget rebuild. + ## 17.0.0 - **BREAKING CHANGE** diff --git a/packages/go_router/example/lib/refresh_example.dart b/packages/go_router/example/lib/refresh_example.dart new file mode 100644 index 00000000000..b6ca5364520 --- /dev/null +++ b/packages/go_router/example/lib/refresh_example.dart @@ -0,0 +1,758 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// The main entry point for the GoRouter refresh example app. +void main() => runApp(const RefreshExampleApp()); + +/// The root widget of the GoRouter refresh example application. +class RefreshExampleApp extends StatelessWidget { + /// Creates a const [RefreshExampleApp]. + const RefreshExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'GoRouter Refresh Example', + routerConfig: _router, + ); + } + + /// The GoRouter configuration. + GoRouter get _router => GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: + (BuildContext context, GoRouterState state) => const HomeScreen(), + ), + GoRoute( + path: '/simple', + builder: + (BuildContext context, GoRouterState state) => + SimpleRefreshScreen(key: state.pageKey), + ), + GoRoute( + path: '/nested/:id', + builder: + (BuildContext context, GoRouterState state) => + NestedScreen(id: state.pathParameters['id']!), + routes: [ + GoRoute( + path: 'detail', + builder: (BuildContext context, GoRouterState state) => + DetailScreen(parentId: state.pathParameters['id']), + ), + ], + ), + ShellRoute( + builder: + (BuildContext context, GoRouterState state, Widget child) => + ShellScreen(child: child), + routes: [ + GoRoute( + path: '/shell/page1', + builder: + (BuildContext context, GoRouterState state) => + ShellPage1(key: state.pageKey), + ), + GoRoute( + path: '/shell/page2', + builder: + (BuildContext context, GoRouterState state) => + ShellPage2(key: state.pageKey), + ), + ], + ), + StatefulShellRoute.indexedStack( + builder: ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + return StatefulShellScreen(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/stateful/tab1', + builder: + (BuildContext context, GoRouterState state) => + StatefulTab1(key: state.pageKey), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/stateful/tab2', + builder: + (BuildContext context, GoRouterState state) => + StatefulTab2(key: state.pageKey), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/stateful/tab3', + builder: + (BuildContext context, GoRouterState state) => + StatefulTab3(key: state.pageKey), + ), + ], + ), + ], + ), + ], + ); +} + +// Data Service +/// A service that provides data. +class DataService { + DataService._(); + + /// The singleton instance of [DataService]. + static final DataService instance = DataService._(); + + final Random _random = Random(); + int _counter = 0; + + /// Returns a string with the current counter and timestamp. + String getData() { + _counter++; + return 'Data #$_counter - ${DateTime.now().millisecondsSinceEpoch}'; + } + + /// Returns a random number between 0 and 99. + int getRandomNumber() => _random.nextInt(100); +} + +/// The home screen. +class HomeScreen extends StatelessWidget { + /// Creates a const [HomeScreen]. + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Refresh Examples')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + ElevatedButton( + onPressed: () => context.go('/simple'), + child: const Text('Simple Refresh'), + ), + ElevatedButton( + onPressed: () => context.go('/nested/123'), + child: const Text('Nested Routes with Refresh'), + ), + ElevatedButton( + onPressed: () => context.go('/shell/page1'), + child: const Text('Shell Route with Refresh'), + ), + ElevatedButton( + onPressed: () => context.go('/stateful/tab1'), + child: const Text('Stateful Shell Route with Refresh'), + ), + ], + ), + ); + } +} + +/// The simple refresh screen. +class SimpleRefreshScreen extends StatefulWidget { + /// Creates a const [SimpleRefreshScreen]. + const SimpleRefreshScreen({super.key}); + + @override + State createState() => SimpleRefreshScreenState(); +} + +/// The state for the [SimpleRefreshScreen]. +class SimpleRefreshScreenState extends State { + late String _data; + Timer? _timer; + bool _autoRefresh = false; + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _loadData() { + setState(() { + _data = DataService.instance.getData(); + }); + } + + void _toggleAutoRefresh() { + setState(() { + _autoRefresh = !_autoRefresh; + if (_autoRefresh) { + _timer = Timer.periodic(const Duration(seconds: 2), (_) { + if (mounted) { + GoRouter.of(context).refresh(); + } + }); + } else { + _timer?.cancel(); + } + }); + } + + @override + void didUpdateWidget(covariant SimpleRefreshScreen oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Simple Refresh'), + actions: [ + IconButton( + icon: Icon(_autoRefresh ? Icons.pause : Icons.play_arrow), + onPressed: _toggleAutoRefresh, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_data, style: const TextStyle(fontSize: 20)), + const SizedBox(height: 20), + if (_autoRefresh) + const Text( + 'Auto-refresh enabled', + style: TextStyle(color: Colors.green), + ), + ], + ), + ), + ); + } +} + +/// The screen for the nested route. +class NestedScreen extends StatefulWidget { + /// The screen for the nested route. + const NestedScreen({required this.id, super.key}); + + /// The ID of the nested route. + final String id; + + @override + State createState() => NestedScreenState(); +} + +/// The state for the [NestedScreen]. +class NestedScreenState extends State { + late String _data; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _data = 'Parent ${widget.id}: ${DataService.instance.getData()}'; + }); + } + + @override + void didUpdateWidget(covariant NestedScreen oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Nested ${widget.id}'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_data), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => context.go('/nested/${widget.id}/detail'), + child: const Text('Go to Detail'), + ), + ], + ), + ), + ); + } +} + +/// The screen for the detail route. +class DetailScreen extends StatefulWidget { + /// The screen for the detail route. + const DetailScreen({this.parentId, super.key}); + + /// The ID of the parent route. + final String? parentId; + + @override + State createState() => DetailScreenState(); +} + +/// The state for the [DetailScreen]. +class DetailScreenState extends State { + late String _data; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _data = + 'Detail for ${widget.parentId}: ${DataService.instance.getData()}'; + }); + } + + @override + void didUpdateWidget(covariant DetailScreen oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Detail'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center(child: Text(_data)), + ); + } +} + +/// The shell screen for the shell route. +class ShellScreen extends StatelessWidget { + /// The shell screen for the shell route. + const ShellScreen({required this.child, super.key}); + + /// The child widget. + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: + GoRouterState.of(context).uri.path == '/shell/page1' ? 0 : 1, + onTap: (int index) { + if (index == 0) { + context.go('/shell/page1'); + } else { + context.go('/shell/page2'); + } + }, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Page 1'), + BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Page 2'), + ], + ), + ); + } +} + +/// The first page of the shell route. +class ShellPage1 extends StatefulWidget { + /// The first page of the shell route. + const ShellPage1({super.key}); + + @override + State createState() => ShellPage1State(); +} + +/// The state for the [ShellPage1]. +class ShellPage1State extends State { + late int _number; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _number = DataService.instance.getRandomNumber(); + }); + } + + @override + void didUpdateWidget(covariant ShellPage1 oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Shell Page 1'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center( + child: Text('Random: $_number', style: const TextStyle(fontSize: 24)), + ), + ); + } +} + +/// The second page of the shell route. +class ShellPage2 extends StatefulWidget { + /// The second page of the shell route. + const ShellPage2({super.key}); + + @override + State createState() => ShellPage2State(); +} + +/// The state for the [ShellPage2]. +class ShellPage2State extends State { + late String _data; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _data = DataService.instance.getData(); + }); + } + + @override + void didUpdateWidget(covariant ShellPage2 oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Shell Page 2'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center(child: Text(_data)), + ); + } +} + +/// The stateful shell screen for the stateful shell route. +class StatefulShellScreen extends StatelessWidget { + /// The stateful shell screen for the stateful shell route. + const StatefulShellScreen({required this.navigationShell, super.key}); + + /// The navigation shell. + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + onTap: (int index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + }, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Tab 1'), + BottomNavigationBarItem(icon: Icon(Icons.business), label: 'Tab 2'), + BottomNavigationBarItem(icon: Icon(Icons.school), label: 'Tab 3'), + ], + ), + ); + } +} + +/// The first tab of the stateful shell route. +class StatefulTab1 extends StatefulWidget { + /// The first tab of the stateful shell route. + const StatefulTab1({super.key}); + + @override + State createState() => StatefulTab1State(); +} + +/// The state for the [StatefulTab1]. +class StatefulTab1State extends State { + late String _data; + int _refreshCount = 0; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _data = DataService.instance.getData(); + _refreshCount++; + }); + } + + @override + void didUpdateWidget(covariant StatefulTab1 oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Stateful Tab 1'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_data, style: const TextStyle(fontSize: 18)), + const SizedBox(height: 20), + Text('Refresh count: $_refreshCount'), + const SizedBox(height: 20), + const Text( + 'State is preserved when switching tabs', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ), + ); + } +} + +/// The second tab of the stateful shell route. +class StatefulTab2 extends StatefulWidget { + /// The second tab of the stateful shell route. + const StatefulTab2({super.key}); + + @override + State createState() => StatefulTab2State(); +} + +/// The state for the [StatefulTab2]. +class StatefulTab2State extends State { + late int _number; + int _tapCount = 0; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _number = DataService.instance.getRandomNumber(); + }); + } + + void _incrementTap() { + setState(() { + _tapCount++; + }); + } + + @override + void didUpdateWidget(covariant StatefulTab2 oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Stateful Tab 2'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Random: $_number', style: const TextStyle(fontSize: 24)), + const SizedBox(height: 20), + Text('Tap count: $_tapCount'), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _incrementTap, + child: const Text('Increment Tap Count'), + ), + ], + ), + ), + ); + } +} + +/// The third tab of the stateful shell route. +class StatefulTab3 extends StatefulWidget { + /// The third tab of the stateful shell route. + const StatefulTab3({super.key}); + + @override + State createState() => StatefulTab3State(); +} + +/// The state for the [StatefulTab3]. +class StatefulTab3State extends State { + late String _data; + final List _history = []; + + @override + void initState() { + super.initState(); + _loadData(); + } + + void _loadData() { + setState(() { + _data = DataService.instance.getData(); + _history.add(_data); + }); + } + + @override + void didUpdateWidget(covariant StatefulTab3 oldWidget) { + super.didUpdateWidget(oldWidget); + _loadData(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Stateful Tab 3'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () { + GoRouter.of(context).refresh(); + }, + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text('Current: $_data', style: const TextStyle(fontSize: 18)), + const SizedBox(height: 10), + Text('History count: ${_history.length}'), + ], + ), + ), + const Divider(), + Expanded( + child: ListView.builder( + itemCount: _history.length, + itemBuilder: (BuildContext context, int index) { + return ListTile( + leading: CircleAvatar(child: Text('${index + 1}')), + title: Text(_history[index]), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/go_router/example/test/refresh_example_test.dart b/packages/go_router/example/test/refresh_example_test.dart new file mode 100644 index 00000000000..3f3d7ecd3ef --- /dev/null +++ b/packages/go_router/example/test/refresh_example_test.dart @@ -0,0 +1,376 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:go_router_examples/refresh_example.dart'; + +void main() { + group('Refresh Function Tests', () { + // Helper to get router from tester + GoRouter getRouter(WidgetTester tester) { + final BuildContext context = tester.element(find.byType(Scaffold).first); + return GoRouter.of(context); + } + + testWidgets('refresh() creates new configuration', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + final GoRouter router = getRouter(tester); + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + + await tester.pump(const Duration(milliseconds: 10)); + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + + // Verify that configuration changed + expect(matchesAfter, isNot(same(matchesBefore))); + }); + + testWidgets('refresh() on simple route triggers data reload', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + + final Finder dataFinder = find.textContaining('Data #'); + expect(dataFinder, findsOneWidget); + final String initialData = tester.widget(dataFinder).data!; + + await tester.pump(const Duration(milliseconds: 10)); + getRouter(tester).refresh(); + await tester.pump(); + + final String updatedData = tester.widget(dataFinder).data!; + expect(updatedData, isNot(equals(initialData))); + }); + + testWidgets('refresh() preserves path parameters', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/nested/456'); + await tester.pumpAndSettle(); + + expect(find.text('Nested 456'), findsOneWidget); + + final RouteMatchList matchesBefore = + getRouter(tester).routerDelegate.currentConfiguration; + + getRouter(tester).refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + getRouter(tester).routerDelegate.currentConfiguration; + + expect(matchesAfter.pathParameters['id'], equals('456')); + expect(matchesBefore.uri.path, equals(matchesAfter.uri.path)); + }); + + testWidgets('refresh() on nested detail route', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/nested/789/detail'); + await tester.pumpAndSettle(); + + expect(find.text('Detail'), findsOneWidget); + + final Finder dataFinder = find.textContaining('Detail for 789:'); + final String initialData = tester.widget(dataFinder).data!; + + await tester.pump(const Duration(milliseconds: 10)); + getRouter(tester).refresh(); + await tester.pump(); + + final String updatedData = tester.widget(dataFinder).data!; + expect(updatedData, isNot(equals(initialData))); + }); + + testWidgets('refresh() preserves query parameters', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple?test=value&count=5'); + await tester.pumpAndSettle(); + + final RouteMatchList matchesBefore = + getRouter(tester).routerDelegate.currentConfiguration; + + getRouter(tester).refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + getRouter(tester).routerDelegate.currentConfiguration; + + expect(matchesAfter.uri.queryParameters['test'], equals('value')); + expect(matchesAfter.uri.queryParameters['count'], equals('5')); + expect(matchesBefore.uri.toString(), equals(matchesAfter.uri.toString())); + }); + + testWidgets('multiple refresh() calls create different configurations', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + + final List configurations = []; + + for (int i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 10)); + getRouter(tester).refresh(); + await tester.pump(); + + configurations.add( + getRouter(tester).routerDelegate.currentConfiguration, + ); + } + + // All configurations should be different objects + expect(configurations[0], isNot(same(configurations[1]))); + expect(configurations[1], isNot(same(configurations[2]))); + expect(configurations[0], isNot(same(configurations[2]))); + }); + + testWidgets('refresh() on ShellRoute', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/shell/page1'); + await tester.pumpAndSettle(); + + expect(find.text('Shell Page 1'), findsOneWidget); + + getRouter(tester).refresh(); + await tester.pump(); + + expect(find.text('Shell Page 1'), findsOneWidget); + }); + + testWidgets('refresh() on StatefulShellRoute', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/stateful/tab1'); + await tester.pumpAndSettle(); + + expect(find.text('Stateful Tab 1'), findsOneWidget); + + getRouter(tester).refresh(); + await tester.pump(); + + expect(find.text('Stateful Tab 1'), findsOneWidget); + }); + + testWidgets('refresh() maintains navigation stack depth', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/nested/111/detail'); + await tester.pumpAndSettle(); + + expect(find.text('Detail'), findsOneWidget); + + final RouteMatchList matchesBefore = + getRouter(tester).routerDelegate.currentConfiguration; + + getRouter(tester).refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + getRouter(tester).routerDelegate.currentConfiguration; + + expect(matchesAfter.matches.length, equals(matchesBefore.matches.length)); + expect(matchesAfter.uri.path, equals('/nested/111/detail')); + }); + + testWidgets('refresh() works with push navigation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + + await getRouter(tester).push('/nested/222'); + await tester.pumpAndSettle(); + + expect(find.text('Nested 222'), findsOneWidget); + + getRouter(tester).refresh(); + await tester.pump(); + + expect(find.text('Nested 222'), findsOneWidget); + }); + + testWidgets('refresh() preserves extra data', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + const Map extraData = {'key': 'value'}; + getRouter(tester).go('/simple', extra: extraData); + await tester.pumpAndSettle(); + + final RouteMatchList matchesBefore = + getRouter(tester).routerDelegate.currentConfiguration; + expect(matchesBefore.extra, equals(extraData)); + + getRouter(tester).refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + getRouter(tester).routerDelegate.currentConfiguration; + expect(matchesAfter.extra, equals(extraData)); + }); + + testWidgets('refresh() can be called rapidly', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + + // Call refresh multiple times rapidly + for (int i = 0; i < 5; i++) { + getRouter(tester).refresh(); + await tester.pump(const Duration(milliseconds: 1)); + } + + expect(find.text('Simple Refresh'), findsOneWidget); + }); + + testWidgets('refresh() on different route types', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + // Home route + getRouter(tester).refresh(); + await tester.pump(); + expect(find.byType(MaterialApp), findsOneWidget); + + // Simple route + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + getRouter(tester).refresh(); + await tester.pump(); + expect(find.text('Simple Refresh'), findsOneWidget); + + // Nested route + getRouter(tester).go('/nested/333'); + await tester.pumpAndSettle(); + getRouter(tester).refresh(); + await tester.pump(); + expect(find.text('Nested 333'), findsOneWidget); + + // Shell route + getRouter(tester).go('/shell/page2'); + await tester.pumpAndSettle(); + getRouter(tester).refresh(); + await tester.pump(); + expect(find.text('Shell Page 2'), findsOneWidget); + + // Stateful shell route + getRouter(tester).go('/stateful/tab2'); + await tester.pumpAndSettle(); + getRouter(tester).refresh(); + await tester.pump(); + expect(find.text('Stateful Tab 2'), findsOneWidget); + }); + + testWidgets('refresh() updates data timestamp', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + + final Finder dataFinder = find.textContaining('Data #'); + final String data1 = tester.widget(dataFinder).data!; + + final RegExp timestampRegex = RegExp(r'- (\d+)'); + final int timestamp1 = int.parse( + timestampRegex.firstMatch(data1)!.group(1)!, + ); + + await tester.pump(const Duration(milliseconds: 100)); + getRouter(tester).refresh(); + await tester.pump(); + + final String data2 = tester.widget(dataFinder).data!; + final int timestamp2 = int.parse( + timestampRegex.firstMatch(data2)!.group(1)!, + ); + + expect(timestamp2, greaterThan(timestamp1)); + }); + + testWidgets('refresh() after navigation', (WidgetTester tester) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/simple'); + await tester.pumpAndSettle(); + expect(find.text('Simple Refresh'), findsOneWidget); + + getRouter(tester).go('/'); + await tester.pumpAndSettle(); + + getRouter(tester).refresh(); + await tester.pump(); + + expect(find.byType(MaterialApp), findsOneWidget); + }); + + testWidgets('refresh() maintains URI structure', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const RefreshExampleApp()); + await tester.pumpAndSettle(); + + getRouter(tester).go('/nested/555?query=test#fragment'); + await tester.pumpAndSettle(); + + final RouteMatchList matchesBefore = + getRouter(tester).routerDelegate.currentConfiguration; + + getRouter(tester).refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + getRouter(tester).routerDelegate.currentConfiguration; + + expect(matchesAfter.uri.path, equals(matchesBefore.uri.path)); + expect(matchesAfter.uri.query, equals(matchesBefore.uri.query)); + expect(matchesAfter.uri.fragment, equals(matchesBefore.uri.fragment)); + }); + }); +} diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index 5eb0466cef4..21b592b1b9b 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -218,7 +218,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { pageToRouteMatchBase[page] = match; registry[page] = match.buildState( widget.configuration, - widget.matchList, + match is ImperativeRouteMatch ? match.matches : widget.matchList, ); } } @@ -245,7 +245,7 @@ class _CustomNavigatorState extends State<_CustomNavigator> { final GoRouterPageBuilder? pageBuilder = match.route.pageBuilder; final GoRouterState state = match.buildState( widget.configuration, - widget.matchList, + match is ImperativeRouteMatch ? match.matches : widget.matchList, ); if (pageBuilder != null) { final Page page = pageBuilder(context, state); diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 8e070a8d1cf..53c9131951a 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -378,6 +378,8 @@ class RouteConfiguration { extra: imperativeMatch.matches.extra, ), completer: imperativeMatch.completer, + // Preserve the refresh key when reparsing. + refreshKey: imperativeMatch.refreshKey, ); result = result.push(match); } diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index 27d843b1e47..57f307c43aa 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -52,6 +52,13 @@ abstract class RouteMatchBase with Diagnosticable { RouteMatchList matches, ); + /// A key that is used to force a rebuild of the navigator. + /// + /// When the [GoRouter.refresh] is called, a new unique key is generated + /// and passed to the [RouteMatchBase] of the current route. This is useful + /// when the route needs to be rebuilt with new data. + ValueKey? get refreshKey; + /// Generates a list of [RouteMatchBase] objects by matching the `route` and /// its sub-routes with `uri`. /// @@ -181,6 +188,9 @@ abstract class RouteMatchBase with Diagnosticable { matches: subRouteMatches!.remove(null)!, matchedLocation: remainingLocation, pageKey: ValueKey(route.hashCode.toString()), + refreshKey: ValueKey( + DateTime.now().microsecondsSinceEpoch.toString(), + ), navigatorKey: navigatorKeyUsed, ); subRouteMatches @@ -248,6 +258,9 @@ abstract class RouteMatchBase with Diagnosticable { route: route, matchedLocation: newMatchedLocation, pageKey: ValueKey(newMatchedPath), + refreshKey: ValueKey( + DateTime.now().microsecondsSinceEpoch.toString(), + ), ), ], }; @@ -289,6 +302,9 @@ abstract class RouteMatchBase with Diagnosticable { route: route, matchedLocation: newMatchedLocation, pageKey: ValueKey(newMatchedPath), + refreshKey: ValueKey( + DateTime.now().microsecondsSinceEpoch.toString(), + ), ), ); return subRouteMatches; @@ -311,6 +327,7 @@ class RouteMatch extends RouteMatchBase { required this.route, required this.matchedLocation, required this.pageKey, + this.refreshKey, }); /// The matched route. @@ -323,6 +340,25 @@ class RouteMatch extends RouteMatchBase { @override final ValueKey pageKey; + /// A key that is used to force a rebuild of the navigator. + @override + final ValueKey? refreshKey; + + /// Creates a copy of this RouteMatch with the given fields replaced with the new values. + RouteMatch copyWith({ + GoRoute? route, + String? matchedLocation, + ValueKey? pageKey, + ValueKey? refreshKey, + }) { + return RouteMatch( + route: route ?? this.route, + matchedLocation: matchedLocation ?? this.matchedLocation, + pageKey: pageKey ?? this.pageKey, + refreshKey: refreshKey ?? this.refreshKey, + ); + } + @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { @@ -331,11 +367,12 @@ class RouteMatch extends RouteMatchBase { return other is RouteMatch && route == other.route && matchedLocation == other.matchedLocation && - pageKey == other.pageKey; + pageKey == other.pageKey && + refreshKey == other.refreshKey; } @override - int get hashCode => Object.hash(route, matchedLocation, pageKey); + int get hashCode => Object.hash(route, matchedLocation, pageKey, refreshKey); @override GoRouterState buildState( @@ -369,6 +406,7 @@ class ShellRouteMatch extends RouteMatchBase { required this.matchedLocation, required this.pageKey, required this.navigatorKey, + this.refreshKey, }) : assert(matches.isNotEmpty); @override @@ -394,6 +432,10 @@ class ShellRouteMatch extends RouteMatchBase { @override final ValueKey pageKey; + /// A key that is used to force a rebuild of the navigator. + @override + final ValueKey? refreshKey; + @override GoRouterState buildState( RouteConfiguration configuration, @@ -430,6 +472,7 @@ class ShellRouteMatch extends RouteMatchBase { matchedLocation: matchedLocation, pageKey: pageKey, navigatorKey: navigatorKey, + refreshKey: refreshKey, ); } @@ -454,6 +497,8 @@ class ImperativeRouteMatch extends RouteMatch { required super.pageKey, required this.matches, required this.completer, + /// A key that is used to force a rebuild of the navigator. + super.refreshKey, }) : super( route: _getsLastRouteFromMatches(matches), matchedLocation: _getsMatchedLocationFromMatches(matches), @@ -919,6 +964,7 @@ class RouteMatchListCodec extends Codec> { static const String _extraKey = 'state'; static const String _imperativeMatchesKey = 'imperativeMatches'; static const String _pageKey = 'pageKey'; + static const String _refreshKey = 'refreshKey'; static const String _codecKey = 'codec'; static const String _jsonCodecName = 'json'; static const String _customCodecName = 'custom'; @@ -953,6 +999,7 @@ class _RouteMatchListEncoder e.matches.uri.toString(), e.matches.extra, pageKey: e.pageKey.value, + refreshKey: e.refreshKey?.value, ), ) .toList(); @@ -969,6 +1016,7 @@ class _RouteMatchListEncoder Object? extra, { List>? imperativeMatches, String? pageKey, + String? refreshKey, }) { Map encodedExtra; if (configuration.extraCodec != null) { @@ -1003,6 +1051,7 @@ class _RouteMatchListEncoder if (imperativeMatches != null) RouteMatchListCodec._imperativeMatchesKey: imperativeMatches, if (pageKey != null) RouteMatchListCodec._pageKey: pageKey, + if (refreshKey != null) RouteMatchListCodec._refreshKey: refreshKey, }; } } @@ -1047,12 +1096,18 @@ class _RouteMatchListDecoder final ValueKey pageKey = ValueKey( encodedImperativeMatch[RouteMatchListCodec._pageKey]! as String, ); + final String? refreshKeyValue = + encodedImperativeMatch[RouteMatchListCodec._refreshKey] as String?; + final ValueKey? refreshKey = refreshKeyValue == null + ? null + : ValueKey(refreshKeyValue); final ImperativeRouteMatch imperativeMatch = ImperativeRouteMatch( pageKey: pageKey, // TODO(chunhtai): Figure out a way to preserve future. // https://github.com/flutter/flutter/issues/128122. completer: Completer(), matches: imperativeMatchList, + refreshKey: refreshKey, ); matchList = matchList.push(imperativeMatch); } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 155e1c62bdb..f7e5538b491 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -591,12 +591,57 @@ class GoRouter implements RouterConfig { } /// Refresh the route. + /// + /// This is a more reliable way to force a refresh of the route than just + /// calling `notifyListeners` on the `refreshListenable`. It works by + /// creating a new `RouteMatchList` with a new unique `refreshKey` and then + /// calling `setNewRoutePath` on the `routerDelegate`. This forces the + /// `Navigator` to rebuild its pages, which is useful when you want to + /// refresh the route with new data. void refresh() { assert(() { log('refreshing ${routerDelegate.currentConfiguration.uri}'); return true; }()); - routeInformationProvider.notifyListeners(); + RouteMatchList currentConfiguration = routerDelegate.currentConfiguration; + if (currentConfiguration.matches.isNotEmpty) { + final RouteMatchBase lastMatch = currentConfiguration.matches.last; + if (lastMatch is ImperativeRouteMatch) { + currentConfiguration = lastMatch.matches; + } + } + final ValueKey refreshKey = ValueKey( + DateTime.now().microsecondsSinceEpoch.toString(), + ); + + List refreshMatches( + List matches, + ValueKey key, + ) { + return matches.map((RouteMatchBase match) { + if (match is ShellRouteMatch) { + return match.copyWith(matches: refreshMatches(match.matches, key)); + } + if (match is RouteMatch) { + return match.copyWith(refreshKey: key); + } + return match; + }).toList(); + } + + final List newMatches = refreshMatches( + currentConfiguration.matches, + refreshKey, + ); + + final RouteMatchList newConfiguration = RouteMatchList( + matches: newMatches, + uri: currentConfiguration.uri, + pathParameters: currentConfiguration.pathParameters, + extra: currentConfiguration.extra, + ); + + routerDelegate.setNewRoutePath(newConfiguration); } /// Find the current GoRouter in the widget tree. diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index a65ff345c7d..5c160054f52 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 17.0.0 +version: 17.0.1 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/matching_test.dart b/packages/go_router/test/matching_test.dart index 48cdf5db48c..52a645ef83a 100644 --- a/packages/go_router/test/matching_test.dart +++ b/packages/go_router/test/matching_test.dart @@ -101,7 +101,7 @@ void main() { final RouteMatchList list1 = configuration.findMatch(Uri.parse('/a')); final RouteMatchList list2 = configuration.findMatch(Uri.parse('/b')); - list1.push( + final RouteMatchList listWithPushed = list1.push( ImperativeRouteMatch( pageKey: const ValueKey('/b-p0'), matches: list2, @@ -109,10 +109,15 @@ void main() { ), ); - final Map encoded = codec.encode(list1); + final Map encoded = codec.encode(listWithPushed); final RouteMatchList decoded = codec.decode(encoded); expect(decoded, isNotNull); - expect(decoded, equals(list1)); + expect(decoded.uri, listWithPushed.uri); + expect(decoded.matches.length, listWithPushed.matches.length); + expect((decoded.matches.first as RouteMatch).route, + (listWithPushed.matches.first as RouteMatch).route); + expect((decoded.matches.last as ImperativeRouteMatch).matches.uri, + (listWithPushed.matches.last as ImperativeRouteMatch).matches.uri); }); } diff --git a/packages/go_router/test/refresh_key_test.dart b/packages/go_router/test/refresh_key_test.dart new file mode 100644 index 00000000000..592191ead7d --- /dev/null +++ b/packages/go_router/test/refresh_key_test.dart @@ -0,0 +1,525 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:go_router/src/match.dart'; + +import 'test_helpers.dart'; + +void main() { + group('RefreshKey Tests', () { + // Test 1: RouteMatch.copyWith preserves refreshKey + test('RouteMatch.copyWith preserves refreshKey', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + const ValueKey originalRefreshKey = ValueKey( + 'original-key', + ); + final RouteMatch original = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: originalRefreshKey, + ); + + final RouteMatch copied = original.copyWith(); + + expect(copied.refreshKey, equals(originalRefreshKey)); + expect(copied.route, equals(original.route)); + expect(copied.matchedLocation, equals(original.matchedLocation)); + expect(copied.pageKey, equals(original.pageKey)); + }); + + // Test 2: RouteMatch.copyWith updates refreshKey + test('RouteMatch.copyWith updates refreshKey', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + const ValueKey originalRefreshKey = ValueKey( + 'original-key', + ); + const ValueKey newRefreshKey = ValueKey('new-key'); + + final RouteMatch original = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: originalRefreshKey, + ); + + final RouteMatch copied = original.copyWith(refreshKey: newRefreshKey); + + expect(copied.refreshKey, equals(newRefreshKey)); + expect(copied.refreshKey, isNot(equals(originalRefreshKey))); + }); + + // Test 3: RouteMatch equality includes refreshKey + test('RouteMatch equality includes refreshKey', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final RouteMatch match1 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-1'), + ); + + final RouteMatch match2 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-1'), + ); + + final RouteMatch match3 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-2'), + ); + + expect(match1, equals(match2)); + expect(match1, isNot(equals(match3))); + }); + + // Test 4: RouteMatch hashCode includes refreshKey + test('RouteMatch hashCode includes refreshKey', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final RouteMatch match1 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-1'), + ); + + final RouteMatch match2 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-2'), + ); + + expect(match1.hashCode, isNot(equals(match2.hashCode))); + }); + + // Test 5: RouteMatch with null refreshKey + test('RouteMatch with null refreshKey', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final RouteMatch match1 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + ); + + final RouteMatch match2 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + ); + + expect(match1.refreshKey, isNull); + expect(match1, equals(match2)); + }); + + // Test 6: RouteMatch with different refreshKeys are not equal + test('RouteMatch with different refreshKeys are not equal', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final RouteMatch match1 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-1'), + ); + + final RouteMatch match2 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-2'), + ); + + final RouteMatch match3 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + ); + + expect(match1 == match2, isFalse); + expect(match1 == match3, isFalse); + expect(match2 == match3, isFalse); + }); + + // Test 7: ShellRouteMatch.copyWith preserves refreshKey + test('ShellRouteMatch.copyWith preserves refreshKey', () { + final GlobalKey navigatorKey = + GlobalKey(); + final ShellRoute shellRoute = ShellRoute( + navigatorKey: navigatorKey, + builder: (BuildContext context, GoRouterState state, Widget child) { + return child; + }, + routes: [ + GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + ], + ); + + final GoRoute childRoute = shellRoute.routes.first as GoRoute; + final RouteMatch childMatch = RouteMatch( + route: childRoute, + matchedLocation: '/test', + pageKey: const ValueKey('child-key'), + ); + + const ValueKey originalRefreshKey = ValueKey( + 'shell-refresh-key', + ); + final ShellRouteMatch original = ShellRouteMatch( + route: shellRoute, + matches: [childMatch], + matchedLocation: '/test', + pageKey: const ValueKey('shell-page-key'), + navigatorKey: navigatorKey, + refreshKey: originalRefreshKey, + ); + + final ShellRouteMatch copied = original.copyWith( + matches: original.matches, + ); + + expect(copied.refreshKey, equals(originalRefreshKey)); + }); + + // Test 8: ImperativeRouteMatch preserves refreshKey + test('ImperativeRouteMatch preserves refreshKey', () { + final RouteConfiguration configuration = createRouteConfiguration( + routes: [ + GoRoute( + path: '/a', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + ], + redirectLimit: 0, + navigatorKey: GlobalKey(), + topRedirect: (_, __) => null, + ); + + final RouteMatchList matchList = configuration.findMatch(Uri.parse('/a')); + const ValueKey refreshKey = ValueKey('imperative-key'); + + final ImperativeRouteMatch imperativeMatch = ImperativeRouteMatch( + pageKey: const ValueKey('page-key'), + matches: matchList, + completer: Completer(), + refreshKey: refreshKey, + ); + + expect(imperativeMatch.refreshKey, equals(refreshKey)); + }); + + // Test 9: RouteMatchListCodec encodes and decodes refreshKey + test('RouteMatchListCodec encodes and decodes refreshKey', () { + final RouteConfiguration configuration = createRouteConfiguration( + routes: [ + GoRoute( + path: '/a', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + GoRoute( + path: '/b', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + ], + redirectLimit: 0, + navigatorKey: GlobalKey(), + topRedirect: (_, __) => null, + ); + + final RouteMatchListCodec codec = RouteMatchListCodec(configuration); + + final RouteMatchList list1 = configuration.findMatch(Uri.parse('/a')); + final RouteMatchList list2 = configuration.findMatch(Uri.parse('/b')); + + const ValueKey refreshKey = ValueKey('test-refresh-key'); + final ImperativeRouteMatch imperativeMatch = ImperativeRouteMatch( + pageKey: const ValueKey('/b-p0'), + matches: list2, + completer: Completer(), + refreshKey: refreshKey, + ); + + final RouteMatchList listWithPushed = list1.push(imperativeMatch); + + final Map encoded = codec.encode(listWithPushed); + final RouteMatchList decoded = codec.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded.matches.length, equals(listWithPushed.matches.length)); + + final ImperativeRouteMatch decodedImperative = + decoded.matches.last as ImperativeRouteMatch; + expect(decodedImperative.refreshKey, isNotNull); + expect(decodedImperative.refreshKey!.value, equals(refreshKey.value)); + }); + + // Test 10: RouteMatchListCodec handles null refreshKey + test('RouteMatchListCodec handles null refreshKey', () { + final RouteConfiguration configuration = createRouteConfiguration( + routes: [ + GoRoute( + path: '/a', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + GoRoute( + path: '/b', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + ], + redirectLimit: 0, + navigatorKey: GlobalKey(), + topRedirect: (_, __) => null, + ); + + final RouteMatchListCodec codec = RouteMatchListCodec(configuration); + + final RouteMatchList list1 = configuration.findMatch(Uri.parse('/a')); + final RouteMatchList list2 = configuration.findMatch(Uri.parse('/b')); + + final ImperativeRouteMatch imperativeMatch = ImperativeRouteMatch( + pageKey: const ValueKey('/b-p0'), + matches: list2, + completer: Completer(), + // No refreshKey provided + ); + + final RouteMatchList listWithPushed = list1.push(imperativeMatch); + + final Map encoded = codec.encode(listWithPushed); + final RouteMatchList decoded = codec.decode(encoded); + + expect(decoded, isNotNull); + final ImperativeRouteMatch decodedImperative = + decoded.matches.last as ImperativeRouteMatch; + expect(decodedImperative.refreshKey, isNull); + }); + + // Test 11: Configuration.reparse preserves refreshKey + test('Configuration.reparse preserves refreshKey', () { + final RouteConfiguration configuration = createRouteConfiguration( + routes: [ + GoRoute( + path: '/a', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + GoRoute( + path: '/b', + builder: + (BuildContext context, GoRouterState state) => + const Placeholder(), + ), + ], + redirectLimit: 0, + navigatorKey: GlobalKey(), + topRedirect: (_, __) => null, + ); + + final RouteMatchList list1 = configuration.findMatch(Uri.parse('/a')); + final RouteMatchList list2 = configuration.findMatch(Uri.parse('/b')); + + const ValueKey refreshKey = ValueKey('preserved-key'); + final ImperativeRouteMatch imperativeMatch = ImperativeRouteMatch( + pageKey: const ValueKey('/b-p0'), + matches: list2, + completer: Completer(), + refreshKey: refreshKey, + ); + + final RouteMatchList listWithPushed = list1.push(imperativeMatch); + + // Reparse the match list + final RouteMatchList reparsed = configuration.reparse(listWithPushed); + + expect(reparsed, isNotNull); + expect(reparsed.matches.length, equals(listWithPushed.matches.length)); + + final ImperativeRouteMatch reparsedImperative = + reparsed.matches.last as ImperativeRouteMatch; + expect(reparsedImperative.refreshKey, isNotNull); + expect(reparsedImperative.refreshKey!.value, equals(refreshKey.value)); + }); + + // Test 12: RouteMatchList with refreshKey in matches + test('RouteMatchList with refreshKey in matches', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final RouteMatch match1 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('refresh-1'), + ); + + final RouteMatch match2 = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('refresh-2'), + ); + + final RouteMatchList list1 = RouteMatchList( + matches: [match1], + uri: Uri.parse('/test'), + pathParameters: const {}, + ); + + final RouteMatchList list2 = RouteMatchList( + matches: [match2], + uri: Uri.parse('/test'), + pathParameters: const {}, + ); + + // Lists should not be equal because refreshKeys are different + expect(list1 == list2, isFalse); + }); + + // Test 13: RouteMatch.copyWith with all parameters + test('RouteMatch.copyWith with all parameters', () { + final GoRoute route1 = GoRoute( + path: '/test1', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final GoRoute route2 = GoRoute( + path: '/test2', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final RouteMatch original = RouteMatch( + route: route1, + matchedLocation: '/test1', + pageKey: const ValueKey('page-key-1'), + refreshKey: const ValueKey('refresh-key-1'), + ); + + final RouteMatch copied = original.copyWith( + route: route2, + matchedLocation: '/test2', + pageKey: const ValueKey('page-key-2'), + refreshKey: const ValueKey('refresh-key-2'), + ); + + expect(copied.route, equals(route2)); + expect(copied.matchedLocation, equals('/test2')); + expect(copied.pageKey.value, equals('page-key-2')); + expect(copied.refreshKey!.value, equals('refresh-key-2')); + }); + + // Test 14: Multiple RouteMatches with different refreshKeys + test('Multiple RouteMatches with different refreshKeys', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + final List matches = [ + RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-1'), + ), + RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-2'), + ), + RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + refreshKey: const ValueKey('key-3'), + ), + ]; + + // All matches should be different + expect(matches[0] == matches[1], isFalse); + expect(matches[1] == matches[2], isFalse); + expect(matches[0] == matches[2], isFalse); + }); + + // Test 15: RefreshKey is optional + test('RefreshKey is optional in RouteMatch', () { + final GoRoute route = GoRoute( + path: '/test', + builder: + (BuildContext context, GoRouterState state) => const Placeholder(), + ); + + // Should not throw when refreshKey is not provided + final RouteMatch match = RouteMatch( + route: route, + matchedLocation: '/test', + pageKey: const ValueKey('page-key'), + ); + + expect(match.refreshKey, isNull); + expect(match.route, equals(route)); + expect(match.matchedLocation, equals('/test')); + }); + }); +} diff --git a/packages/go_router/test/refresh_key_widget_test.dart b/packages/go_router/test/refresh_key_widget_test.dart new file mode 100644 index 00000000000..28fde2cdab4 --- /dev/null +++ b/packages/go_router/test/refresh_key_widget_test.dart @@ -0,0 +1,388 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + group('RefreshKey Widget Tests', () { + testWidgets('refresh() creates new refreshKey for simple route', ( + WidgetTester tester, + ) async { + final List routes = [ + GoRoute( + path: '/page', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/page', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + final RouteMatch routeMatchBefore = + matchesBefore.matches.first as RouteMatch; + final ValueKey? refreshKeyBefore = routeMatchBefore.refreshKey; + + // Wait to ensure timestamp changes + await tester.pump(const Duration(milliseconds: 10)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + final RouteMatch routeMatchAfter = + matchesAfter.matches.first as RouteMatch; + final ValueKey? refreshKeyAfter = routeMatchAfter.refreshKey; + + expect(refreshKeyAfter, isNotNull); + expect(refreshKeyBefore, isNot(equals(refreshKeyAfter))); + }); + + testWidgets('refresh() updates refreshKey for nested routes', ( + WidgetTester tester, + ) async { + final List routes = [ + GoRoute( + path: '/parent', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + routes: [ + GoRoute( + path: 'child', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/parent/child', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + final List?> refreshKeysBefore = + matchesBefore.matches + .map((RouteMatchBase m) => m.refreshKey) + .toList(); + + await tester.pump(const Duration(milliseconds: 10)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + final List?> refreshKeysAfter = + matchesAfter.matches.map((RouteMatchBase m) => m.refreshKey).toList(); + + expect(refreshKeysAfter.length, equals(refreshKeysBefore.length)); + for (int i = 0; i < refreshKeysAfter.length; i++) { + expect(refreshKeysAfter[i], isNotNull); + expect(refreshKeysBefore[i], isNot(equals(refreshKeysAfter[i]))); + } + }); + + testWidgets('refresh() with ShellRoute updates child routes', ( + WidgetTester tester, + ) async { + final GlobalKey shellNavigatorKey = + GlobalKey(); + final List routes = [ + ShellRoute( + navigatorKey: shellNavigatorKey, + builder: (BuildContext context, GoRouterState state, Widget child) { + return child; + }, + routes: [ + GoRoute( + path: '/a', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/a', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + final ShellRouteMatch shellMatchBefore = + matchesBefore.matches.first as ShellRouteMatch; + final RouteMatch childMatchBefore = + shellMatchBefore.matches.first as RouteMatch; + final ValueKey? childRefreshKeyBefore = + childMatchBefore.refreshKey; + + await tester.pump(const Duration(milliseconds: 10)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + final ShellRouteMatch shellMatchAfter = + matchesAfter.matches.first as ShellRouteMatch; + final RouteMatch childMatchAfter = + shellMatchAfter.matches.first as RouteMatch; + final ValueKey? childRefreshKeyAfter = childMatchAfter.refreshKey; + + // Child route should have new refresh key + expect(childRefreshKeyAfter, isNotNull); + expect(childRefreshKeyBefore, isNot(equals(childRefreshKeyAfter))); + }); + + testWidgets('refresh() maintains route state', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/page', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/page', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + final String uriBefore = matchesBefore.uri.toString(); + final String matchedLocationBefore = + matchesBefore.matches.first.matchedLocation; + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + final String uriAfter = matchesAfter.uri.toString(); + final String matchedLocationAfter = + matchesAfter.matches.first.matchedLocation; + + // URI and matched location should remain the same + expect(uriAfter, equals(uriBefore)); + expect(matchedLocationAfter, equals(matchedLocationBefore)); + }); + + testWidgets('refresh() with query parameters', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/page', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/page?param=value', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + final ValueKey? refreshKeyBefore = + (matchesBefore.matches.first as RouteMatch).refreshKey; + + await tester.pump(const Duration(milliseconds: 10)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + final ValueKey? refreshKeyAfter = + (matchesAfter.matches.first as RouteMatch).refreshKey; + + expect(matchesAfter.uri.queryParameters['param'], equals('value')); + expect(refreshKeyAfter, isNotNull); + expect(refreshKeyBefore, isNot(equals(refreshKeyAfter))); + }); + + testWidgets('refresh() with path parameters', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/user/:id', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/user/123', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + final ValueKey? refreshKeyBefore = + (matchesBefore.matches.first as RouteMatch).refreshKey; + + await tester.pump(const Duration(milliseconds: 10)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + final ValueKey? refreshKeyAfter = + (matchesAfter.matches.first as RouteMatch).refreshKey; + + expect(matchesAfter.pathParameters['id'], equals('123')); + expect(refreshKeyAfter, isNotNull); + expect(refreshKeyBefore, isNot(equals(refreshKeyAfter))); + }); + + testWidgets('multiple refresh() calls create different keys', ( + WidgetTester tester, + ) async { + final List routes = [ + GoRoute( + path: '/page', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/page', + ); + + final List?> refreshKeys = ?>[]; + + for (int i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 10)); + router.refresh(); + await tester.pump(); + + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; + final ValueKey? refreshKey = + (matches.matches.first as RouteMatch).refreshKey; + refreshKeys.add(refreshKey); + } + + // All refresh keys should be different + expect(refreshKeys[0], isNot(equals(refreshKeys[1]))); + expect(refreshKeys[1], isNot(equals(refreshKeys[2]))); + expect(refreshKeys[0], isNot(equals(refreshKeys[2]))); + }); + + testWidgets('refresh() preserves extra data', (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/page', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + + const Map extraData = {'key': 'value'}; + router.go('/page', extra: extraData); + await tester.pump(); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + expect(matchesBefore.extra, equals(extraData)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + expect(matchesAfter.extra, equals(extraData)); + }); + + testWidgets('refresh() works with deeply nested routes', ( + WidgetTester tester, + ) async { + final List routes = [ + GoRoute( + path: '/level1', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + routes: [ + GoRoute( + path: 'level2', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + routes: [ + GoRoute( + path: 'level3', + builder: + (BuildContext context, GoRouterState state) => + const DummyScreen(), + ), + ], + ), + ], + ), + ]; + + final GoRouter router = await createRouter( + routes, + tester, + initialLocation: '/level1/level2/level3', + ); + + final RouteMatchList matchesBefore = + router.routerDelegate.currentConfiguration; + expect(matchesBefore.matches.length, equals(3)); + + await tester.pump(const Duration(milliseconds: 10)); + + router.refresh(); + await tester.pump(); + + final RouteMatchList matchesAfter = + router.routerDelegate.currentConfiguration; + expect(matchesAfter.matches.length, equals(3)); + + // All matches should have new refresh keys + for (int i = 0; i < matchesAfter.matches.length; i++) { + final ValueKey? keyBefore = matchesBefore.matches[i].refreshKey; + final ValueKey? keyAfter = matchesAfter.matches[i].refreshKey; + expect(keyAfter, isNotNull); + expect(keyBefore, isNot(equals(keyAfter))); + } + }); + }); +}