Router replaces browser history entry if state changes (#83509)
diff --git a/packages/flutter/lib/src/services/system_navigator.dart b/packages/flutter/lib/src/services/system_navigator.dart index 8a6b5b9..1c3d05d 100644 --- a/packages/flutter/lib/src/services/system_navigator.dart +++ b/packages/flutter/lib/src/services/system_navigator.dart
@@ -67,20 +67,34 @@ /// Notifies the platform for a route information change. /// - /// On web, creates a new browser history entry and update URL with the route - /// information. Whether the history holds one entry or multiple entries is - /// determined by [selectSingleEntryHistory] and [selectMultiEntryHistory]. + /// On web, this method behaves differently based on the single-entry or + /// multiple-entries history mode. Use the [selectSingleEntryHistory] and + /// [selectMultiEntryHistory] to toggle between modes. /// - /// Currently, this is ignored on other platforms. + /// For single-entry mode, this method replaces the current URL and state in + /// the current history entry. The flag `replace` is ignored. + /// + /// For multiple-entries mode, this method creates a new history entry on top + /// of the current entry if the `replace` is false, thus the user will + /// be on a new history entry as if the user has visited a new page, and the + /// browser back button brings the user back to the previous entry. If + /// `replace` is true, this method only updates the URL and the state in the + /// current history entry without pushing a new one. + /// + /// This method is ignored on other platforms. + /// + /// The `replace` flag defaults to false. static Future<void> routeInformationUpdated({ required String location, Object? state, + bool replace = false, }) { return SystemChannels.navigation.invokeMethod<void>( 'routeInformationUpdated', <String, dynamic>{ 'location': location, 'state': state, + 'replace': replace, }, ); }
diff --git a/packages/flutter/lib/src/widgets/router.dart b/packages/flutter/lib/src/widgets/router.dart index 8610aa2..a995eff 100644 --- a/packages/flutter/lib/src/widgets/router.dart +++ b/packages/flutter/lib/src/widgets/router.dart
@@ -198,16 +198,28 @@ /// retrieve the new route information from the [routerDelegate]'s /// [RouterDelegate.currentConfiguration] method and the /// [routeInformationParser]'s [RouteInformationParser.restoreRouteInformation] -/// method. If the location in the new route information is different from the -/// current location, the router sends the new route information to the -/// [routeInformationProvider]'s -/// [RouteInformationProvider.routerReportsNewRouteInformation] method. That -/// method as implemented in [PlatformRouteInformationProvider] uses -/// [SystemNavigator.routeInformationUpdated] to notify the engine, and through -/// that the browser, of the new URL. +/// method. /// -/// One can force the [Router] to report new route information to the -/// [routeInformationProvider] (and thus the browser) even if the +/// If the location in the new route information is different from the +/// current location, this is considered to be a navigation event, the router +/// sends the new route information to the [routeInformationProvider]'s +/// [RouteInformationProvider.routerReportsNewRouteInformation] method with +/// `isNavigation` equals to true. That method as implemented in +/// [PlatformRouteInformationProvider] uses +/// [SystemNavigator.routeInformationUpdated] to notify the engine, and through +/// that the browser, to create a history entry with the new url if the +/// `isNavigation` is true. +/// +/// If the location is the same as the current location but different state, +/// the router still sends the new route information to the +/// [routeInformationProvider]'s +/// [RouteInformationProvider.routerReportsNewRouteInformation] but with +/// `isNavigation` equals to false. This causes +/// [PlatformRouteInformationProvider] replace current history entry instead +/// of creating a new one. +/// +/// One can force the [Router] to report new route information as navigation +/// event to the [routeInformationProvider] (and thus the browser) even if the /// [RouteInformation.location] has not changed by calling the [Router.navigate] /// method with a callback that performs the state change. This allows one to /// support the browser's back and forward buttons without changing the URL. For @@ -218,10 +230,10 @@ /// clicks the back button, the app will go back to the previous scroll position /// without changing the URL in the location bar. /// -/// One can also force the [Router] to ignore application state changes by -/// making those changes during a callback passed to [Router.neglect]. The -/// [Router] will not report any route information even if it detects location -/// change as a result of running the callback. +/// One can also force the [Router] to ignore a navigation event by making +/// those changes during a callback passed to [Router.neglect]. The [Router] +/// will not report the route information with `isNavigation` equals to false +/// even if it detects location change as the result of running the callback. /// /// To opt out of URL updates entirely, pass null for [routeInformationProvider] /// and [routeInformationParser]. This is not recommended in general, but may be @@ -392,8 +404,8 @@ return scope?.routerState.widget as Router<T>?; } - /// Forces the [Router] to run the [callback] and reports the route - /// information back to the engine. + /// Forces the [Router] to run the [callback] and create a new history + /// entry in the browser. /// /// The web application relies on the [Router] to report new route information /// in order to create browser history entry. The [Router] will only report @@ -414,8 +426,8 @@ /// /// * [Router]: see the "URL updates for web applications" section for more /// information about route information reporting. - /// * [neglect]: which forces the [Router] to not report the route - /// information even if location does change. + /// * [neglect]: which forces the [Router] to not create a new history entry + /// even if location does change. static void navigate(BuildContext context, VoidCallback callback) { final _RouterScope scope = context .getElementForInheritedWidgetOfExactType<_RouterScope>()! @@ -423,24 +435,27 @@ scope.routerState._setStateWithExplicitReportStatus(_IntentionToReportRouteInformation.must, callback); } - /// Forces the [Router] to run the [callback] without reporting the route - /// information back to the engine. - /// - /// Use this method if you don't want the [Router] to report the new route - /// information even if it detects changes as a result of running the - /// [callback]. + /// Forces the [Router] to run the [callback] without creating a new history + /// entry in the browser. /// /// The web application relies on the [Router] to report new route information /// in order to create browser history entry. The [Router] will report them - /// automatically if it detects the [RouteInformation.location] changes. You - /// can use this method if you want to navigate to a new route without - /// creating the browser history entry. + /// automatically if it detects the [RouteInformation.location] changes. + /// + /// Creating a new route history entry makes users feel they have visited a + /// new page, and the browser back button brings them back to previous history + /// entry. Use this method if you don't want the [Router] to create a new + /// route information even if it detects changes as a result of running the + /// [callback]. + /// + /// Using this method will still update the URL and state in current history + /// entry. /// /// See also: /// /// * [Router]: see the "URL updates for web applications" section for more /// information about route information reporting. - /// * [navigate]: which forces the [Router] to report the route information + /// * [navigate]: which forces the [Router] to create a new history entry /// even if location does not change. static void neglect(BuildContext context, VoidCallback callback) { final _RouterScope scope = context @@ -497,8 +512,6 @@ bool _routeInformationReportingTaskScheduled = false; - String? _lastSeenLocation; - void _scheduleRouteInformationReportingTask() { if (_routeInformationReportingTaskScheduled || widget.routeInformationProvider == null) return; @@ -512,23 +525,29 @@ _routeInformationReportingTaskScheduled = false; if (_routeInformation.value != null) { - final RouteInformation routeInformation = _routeInformation.value!; + final RouteInformation oldRouteInformation = widget.routeInformationProvider!.value; + final RouteInformation currentRouteInformation = _routeInformation.value!; switch (_currentIntentionToReport) { case _IntentionToReportRouteInformation.none: assert(false, '_reportRouteInformation must not be called with _IntentionToReportRouteInformation.none'); return; case _IntentionToReportRouteInformation.ignore: + if (oldRouteInformation.location != currentRouteInformation.location || + oldRouteInformation.state != currentRouteInformation.state) { + widget.routeInformationProvider!.routerReportsNewRouteInformation(currentRouteInformation, isNavigation: false); + } break; case _IntentionToReportRouteInformation.maybe: - if (_lastSeenLocation != routeInformation.location) { - widget.routeInformationProvider!.routerReportsNewRouteInformation(routeInformation); + if (oldRouteInformation.location != currentRouteInformation.location) { + widget.routeInformationProvider!.routerReportsNewRouteInformation(currentRouteInformation); + } else if (oldRouteInformation.state != currentRouteInformation.state) { + widget.routeInformationProvider!.routerReportsNewRouteInformation(currentRouteInformation, isNavigation: false); } break; case _IntentionToReportRouteInformation.must: - widget.routeInformationProvider!.routerReportsNewRouteInformation(routeInformation); + widget.routeInformationProvider!.routerReportsNewRouteInformation(currentRouteInformation); break; } - _lastSeenLocation = routeInformation.location; } _currentIntentionToReport = _IntentionToReportRouteInformation.none; } @@ -621,7 +640,6 @@ void _processRouteInformation(RouteInformation information, ValueGetter<_DelegateRouteSetter<T>> delegateRouteSetter) { _currentRouteInformationParserTransaction = Object(); _currentRouterDelegateTransaction = Object(); - _lastSeenLocation = information.location; widget.routeInformationParser! .parseRouteInformation(information) .then<T>(_verifyRouteInformationParserStillCurrent(_currentRouteInformationParserTransaction, widget)) @@ -1301,8 +1319,7 @@ /// from the [Router] back to the engine by overriding the /// [routerReportsNewRouteInformation]. abstract class RouteInformationProvider extends ValueListenable<RouteInformation> { - /// A callback called when the [Router] widget detects any navigation event - /// due to state changes. + /// A callback called when the [Router] widget reports new route information /// /// The subclasses can override this method to update theirs values or trigger /// other side effects. For example, the [PlatformRouteInformationProvider] @@ -1310,7 +1327,17 @@ /// /// The [routeInformation] is the new route information after the navigation /// event. - void routerReportsNewRouteInformation(RouteInformation routeInformation) {} + /// + /// The [isNavigation] denotes whether the new route information is generated + /// as a result of a navigation event. This information can be useful in a + /// web application, for example, the [PlatformRouteInformationProvider] uses + /// this flag to decide whether to create a browser history entry that enables + /// browser backward and forward buttons. + /// + /// For more information on how [Router] determines a navigation event, see + /// the "URL updates for web applications" section in the [Router] + /// documentation. + void routerReportsNewRouteInformation(RouteInformation routeInformation, {bool isNavigation = true}) {} } /// The route information provider that propagates the platform route information changes. @@ -1333,11 +1360,12 @@ }) : _value = initialRouteInformation; @override - void routerReportsNewRouteInformation(RouteInformation routeInformation) { + void routerReportsNewRouteInformation(RouteInformation routeInformation, {bool isNavigation = true}) { SystemNavigator.selectMultiEntryHistory(); SystemNavigator.routeInformationUpdated( location: routeInformation.location!, state: routeInformation.state, + replace: !isNavigation, ); _value = routeInformation; }
diff --git a/packages/flutter/test/services/system_navigator_test.dart b/packages/flutter/test/services/system_navigator_test.dart index 122c1ca..47bb60e 100644 --- a/packages/flutter/test/services/system_navigator_test.dart +++ b/packages/flutter/test/services/system_navigator_test.dart
@@ -43,11 +43,15 @@ ]); await verify(() => SystemNavigator.routeInformationUpdated(location: 'a'), <Object>[ - isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'a', 'state': null }), + isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'a', 'state': null, 'replace': false }), ]); await verify(() => SystemNavigator.routeInformationUpdated(location: 'a', state: true), <Object>[ - isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'a', 'state': true }), + isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'a', 'state': true, 'replace': false }), + ]); + + await verify(() => SystemNavigator.routeInformationUpdated(location: 'a', state: true, replace: true), <Object>[ + isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'a', 'state': true, 'replace': true }), ]); await verify(() => SystemNavigator.routeUpdated(routeName: 'a', previousRouteName: 'b'), <Object>[
diff --git a/packages/flutter/test/widgets/route_notification_messages_test.dart b/packages/flutter/test/widgets/route_notification_messages_test.dart index 891189d..a94c145 100644 --- a/packages/flutter/test/widgets/route_notification_messages_test.dart +++ b/packages/flutter/test/widgets/route_notification_messages_test.dart
@@ -69,6 +69,7 @@ arguments: <String, dynamic>{ 'location': '/', 'state': null, + 'replace': false, }, ), ]); @@ -86,6 +87,7 @@ arguments: <String, dynamic>{ 'location': '/A', 'state': null, + 'replace': false, }, ), ); @@ -103,6 +105,7 @@ arguments: <String, dynamic>{ 'location': '/', 'state': null, + 'replace': false, }, ), ); @@ -174,6 +177,7 @@ arguments: <String, dynamic>{ 'location': '/', 'state': null, + 'replace': false, }, ), ]); @@ -191,6 +195,7 @@ arguments: <String, dynamic>{ 'location': '/A', 'state': null, + 'replace': false, }, ), ); @@ -208,6 +213,7 @@ arguments: <String, dynamic>{ 'location': '/B', 'state': null, + 'replace': false, }, ), ); @@ -243,6 +249,7 @@ arguments: <String, dynamic>{ 'location': '/home', 'state': null, + 'replace': false, }, ), ]); @@ -294,6 +301,7 @@ isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'update', 'state': 'state', + 'replace': false, }), ]); });
diff --git a/packages/flutter/test/widgets/router_test.dart b/packages/flutter/test/widgets/router_test.dart index dee2ab9..d69973c 100644 --- a/packages/flutter/test/widgets/router_test.dart +++ b/packages/flutter/test/widgets/router_test.dart
@@ -477,11 +477,14 @@ testWidgets('router does report URL change correctly', (WidgetTester tester) async { RouteInformation? reportedRouteInformation; + bool? reportedIsNavigation; final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( - onRouterReport: (RouteInformation information) { + onRouterReport: (RouteInformation information, bool isNavigation) { // Makes sure we only report once after manually cleaning up. expect(reportedRouteInformation, isNull); + expect(reportedIsNavigation, isNull); reportedRouteInformation = information; + reportedIsNavigation = isNavigation; }, ); final SimpleRouterDelegate delegate = SimpleRouterDelegate( @@ -519,35 +522,44 @@ expect(find.text('initial'), findsNothing); expect(find.text('update'), findsOneWidget); expect(reportedRouteInformation!.location, 'update'); + expect(reportedIsNavigation, isTrue); - // The router should not report if only state changes. + // The router should report as non navigation event if only state changes. reportedRouteInformation = null; + reportedIsNavigation = null; delegate.routeInformation = const RouteInformation( location: 'update', state: 'another state', ); await tester.pump(); expect(find.text('update'), findsOneWidget); - expect(reportedRouteInformation, isNull); + expect(reportedRouteInformation!.location, 'update'); + expect(reportedRouteInformation!.state, 'another state'); + expect(reportedIsNavigation, isFalse); reportedRouteInformation = null; + reportedIsNavigation = null; bool result = false; result = await outerDispatcher.invokeCallback(SynchronousFuture<bool>(false)); expect(result, isTrue); await tester.pump(); expect(find.text('popped'), findsOneWidget); expect(reportedRouteInformation!.location, 'popped'); + expect(reportedIsNavigation, isTrue); }); testWidgets('router can be forced to recognize or ignore navigating events', (WidgetTester tester) async { RouteInformation? reportedRouteInformation; + bool? reportedIsNavigation; bool isNavigating = false; late RouteInformation nextRouteInformation; final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( - onRouterReport: (RouteInformation information) { + onRouterReport: (RouteInformation information, bool isNavigation) { // Makes sure we only report once after manually cleaning up. expect(reportedRouteInformation, isNull); + expect(reportedIsNavigation, isNull); reportedRouteInformation = information; + reportedIsNavigation = isNavigation; }, ); provider.value = const RouteInformation( @@ -592,7 +604,10 @@ await tester.pump(); expect(find.text('initial'), findsNothing); expect(find.text('update'), findsOneWidget); - expect(reportedRouteInformation, isNull); + expect(reportedIsNavigation, isFalse); + expect(reportedRouteInformation!.location, 'update'); + reportedIsNavigation = null; + reportedRouteInformation = null; isNavigating = true; // This should not trigger any real navigating event because the @@ -600,13 +615,112 @@ // report a route information because isNavigating = true. await tester.tap(find.byType(ElevatedButton)); await tester.pump(); + expect(reportedIsNavigation, isTrue); expect(reportedRouteInformation!.location, 'update'); + reportedIsNavigation = null; + reportedRouteInformation = null; + }); + + testWidgets('router ignore navigating events updates RouteInformationProvider', (WidgetTester tester) async { + RouteInformation? updatedRouteInformation; + late RouteInformation nextRouteInformation; + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( + onRouterReport: (RouteInformation information, bool isNavigation) { + // This should never be a navigation event. + expect(isNavigation, false); + expect(updatedRouteInformation, isNull); + updatedRouteInformation = information; + }, + ); + provider.value = const RouteInformation( + location: 'initial', + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate(reportConfiguration: true); + delegate.builder = (BuildContext context, RouteInformation? information) { + return ElevatedButton( + child: Text(information!.location!), + onPressed: () { + Router.neglect(context, () { + if (delegate.routeInformation != nextRouteInformation) + delegate.routeInformation = nextRouteInformation; + }); + }, + ); + }; + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + + await tester.pumpWidget(buildBoilerPlate( + Router<RouteInformation>( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + ), + )); + expect(find.text('initial'), findsOneWidget); + expect(updatedRouteInformation, isNull); + + nextRouteInformation = const RouteInformation( + location: 'update', + ); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + expect(find.text('initial'), findsNothing); + expect(find.text('update'), findsOneWidget); + expect(updatedRouteInformation!.location, 'update'); + }); + + testWidgets('state change without location changes updates RouteInformationProvider', (WidgetTester tester) async { + RouteInformation? updatedRouteInformation; + late RouteInformation nextRouteInformation; + final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( + onRouterReport: (RouteInformation information, bool isNavigation) { + // This should never be a navigation event. + expect(isNavigation, false); + expect(updatedRouteInformation, isNull); + updatedRouteInformation = information; + }, + ); + provider.value = const RouteInformation( + location: 'initial', + state: 'state1', + ); + final SimpleRouterDelegate delegate = SimpleRouterDelegate(reportConfiguration: true); + delegate.builder = (BuildContext context, RouteInformation? information) { + return ElevatedButton( + child: Text(information!.location!), + onPressed: () { + delegate.routeInformation = nextRouteInformation; + }, + ); + }; + final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); + + await tester.pumpWidget(buildBoilerPlate( + Router<RouteInformation>( + backButtonDispatcher: outerDispatcher, + routeInformationProvider: provider, + routeInformationParser: SimpleRouteInformationParser(), + routerDelegate: delegate, + ), + )); + expect(find.text('initial'), findsOneWidget); + expect(updatedRouteInformation, isNull); + + nextRouteInformation = const RouteInformation( + location: 'initial', + state: 'state2', + ); + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + expect(updatedRouteInformation!.location, 'initial'); + expect(updatedRouteInformation!.state, 'state2'); }); testWidgets('router does not report when route information is up to date with route information provider', (WidgetTester tester) async { RouteInformation? reportedRouteInformation; final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( - onRouterReport: (RouteInformation information) { + onRouterReport: (RouteInformation information, bool isNavigation) { reportedRouteInformation = information; }, ); @@ -691,6 +805,38 @@ expect(find.text('newTestRouteName'), findsOneWidget); }); + testWidgets('PlatformRouteInformationProvider updates route information', (WidgetTester tester) async { + final List<MethodCall> log = <MethodCall>[]; + TestDefaultBinaryMessengerBinding + .instance! + .defaultBinaryMessenger + .setMockMethodCallHandler( + SystemChannels.navigation, + (MethodCall methodCall) async { + log.add(methodCall); + } + ); + final RouteInformationProvider provider = PlatformRouteInformationProvider( + initialRouteInformation: const RouteInformation( + location: 'initial', + ), + ); + + log.clear(); + provider.routerReportsNewRouteInformation(const RouteInformation(location: 'a', state: true)); + expect(log, <Object>[ + isMethodCall('selectMultiEntryHistory', arguments: null), + isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'a', 'state': true, 'replace': false }), + ]); + + log.clear(); + provider.routerReportsNewRouteInformation(const RouteInformation(location: 'b', state: false), isNavigation: false); + expect(log, <Object>[ + isMethodCall('selectMultiEntryHistory', arguments: null), + isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{ 'location': 'b', 'state': false, 'replace': true }), + ]); + }); + testWidgets('RootBackButtonDispatcher works', (WidgetTester tester) async { final BackButtonDispatcher outerDispatcher = RootBackButtonDispatcher(); final RouteInformationProvider provider = PlatformRouteInformationProvider( @@ -1093,7 +1239,7 @@ testWidgets('Router reports location if it is different from location given by OS', (WidgetTester tester) async { final List<RouteInformation> reportedRouteInformation = <RouteInformation>[]; final SimpleRouteInformationProvider provider = SimpleRouteInformationProvider( - onRouterReport: reportedRouteInformation.add, + onRouterReport: (RouteInformation info, bool isNavigation) => reportedRouteInformation.add(info), )..value = const RouteInformation(location: '/home'); await tester.pumpWidget(buildBoilerPlate( @@ -1131,7 +1277,7 @@ typedef SimpleRouterDelegateBuilder = Widget Function(BuildContext, RouteInformation?); typedef SimpleRouterDelegatePopRoute = Future<bool> Function(); typedef SimpleNavigatorRouterDelegatePopPage<T> = bool Function(Route<T> route, T result); -typedef RouterReportRouterInformation = void Function(RouteInformation); +typedef RouterReportRouterInformation = void Function(RouteInformation, bool); class SimpleRouteInformationParser extends RouteInformationParser<RouteInformation> { SimpleRouteInformationParser(); @@ -1252,8 +1398,9 @@ } @override - void routerReportsNewRouteInformation(RouteInformation routeInformation) { - onRouterReport?.call(routeInformation); + void routerReportsNewRouteInformation(RouteInformation routeInformation, {bool isNavigation = true}) { + _value = routeInformation; + onRouterReport?.call(routeInformation, isNavigation); } }