Unverified Commit c38feb3b authored by chunhtai's avatar chunhtai Committed by GitHub

Router replaces browser history entry if state changes (#83509)

parent 28f311ad
......@@ -67,20 +67,34 @@ class SystemNavigator {
/// 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,
},
);
}
......
......@@ -198,16 +198,28 @@ class RouteInformation {
/// 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
/// method.
///
/// 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, of the new URL.
/// 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 to the
/// [routeInformationProvider] (and thus the browser) even if the
/// 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 @@ class RouteInformation {
/// 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 @@ class Router<T> extends StatefulWidget {
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 @@ class Router<T> extends StatefulWidget {
///
/// * [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 @@ class Router<T> extends StatefulWidget {
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 @@ class _RouterState<T> extends State<Router<T>> with RestorationMixin {
bool _routeInformationReportingTaskScheduled = false;
String? _lastSeenLocation;
void _scheduleRouteInformationReportingTask() {
if (_routeInformationReportingTaskScheduled || widget.routeInformationProvider == null)
return;
......@@ -512,23 +525,29 @@ class _RouterState<T> extends State<Router<T>> with RestorationMixin {
_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 @@ class _RouterState<T> extends State<Router<T>> with RestorationMixin {
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 @@ abstract class RouterDelegate<T> extends Listenable {
/// 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 @@ abstract class RouteInformationProvider extends ValueListenable<RouteInformation
///
/// 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 @@ class PlatformRouteInformationProvider extends RouteInformationProvider with Wid
}) : _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;
}
......
......@@ -43,11 +43,15 @@ void main() {
]);
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>[
......
......@@ -69,6 +69,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/',
'state': null,
'replace': false,
},
),
]);
......@@ -86,6 +87,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/A',
'state': null,
'replace': false,
},
),
);
......@@ -103,6 +105,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/',
'state': null,
'replace': false,
},
),
);
......@@ -174,6 +177,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/',
'state': null,
'replace': false,
},
),
]);
......@@ -191,6 +195,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/A',
'state': null,
'replace': false,
},
),
);
......@@ -208,6 +213,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/B',
'state': null,
'replace': false,
},
),
);
......@@ -243,6 +249,7 @@ void main() {
arguments: <String, dynamic>{
'location': '/home',
'state': null,
'replace': false,
},
),
]);
......@@ -294,6 +301,7 @@ void main() {
isMethodCall('routeInformationUpdated', arguments: <String, dynamic>{
'location': 'update',
'state': 'state',
'replace': false,
}),
]);
});
......
......@@ -477,11 +477,14 @@ testWidgets('ChildBackButtonDispatcher take priority recursively', (WidgetTester
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 @@ testWidgets('ChildBackButtonDispatcher take priority recursively', (WidgetTester
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 @@ testWidgets('ChildBackButtonDispatcher take priority recursively', (WidgetTester
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 @@ testWidgets('ChildBackButtonDispatcher take priority recursively', (WidgetTester
// 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 @@ testWidgets('ChildBackButtonDispatcher take priority recursively', (WidgetTester
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('ChildBackButtonDispatcher take priority recursively', (WidgetTester
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 @@ Widget buildBoilerPlate(Widget child) {
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 @@ class SimpleRouteInformationProvider extends RouteInformationProvider with Chang
}
@override
void routerReportsNewRouteInformation(RouteInformation routeInformation) {
onRouterReport?.call(routeInformation);
void routerReportsNewRouteInformation(RouteInformation routeInformation, {bool isNavigation = true}) {
_value = routeInformation;
onRouterReport?.call(routeInformation, isNavigation);
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment