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);
}
}