未验证 提交 833c6a2e 编写于 作者: C chunhtai 提交者: GitHub

Implement browser history class for router widget (#20794)

上级 0c6c265a
......@@ -25,6 +25,9 @@ abstract class LocationStrategy {
/// The active path in the browser history.
String get path;
/// The state of the current browser history entry.
dynamic get state;
/// Given a path that's internal to the app, create the external url that
/// will be used in the browser.
String prepareExternalUrl(String internalUrl);
......@@ -36,7 +39,7 @@ abstract class LocationStrategy {
void replaceState(dynamic state, String title, String url);
/// Go to the previous history entry.
Future<void> back();
Future<void> back({int count = 1});
}
/// This is an implementation of [LocationStrategy] that uses the browser URL's
......@@ -82,6 +85,9 @@ class HashLocationStrategy extends LocationStrategy {
return path.substring(1);
}
@override
dynamic get state => _platformLocation.state;
@override
String prepareExternalUrl(String internalUrl) {
// It's convention that if the hash path is empty, we omit the `#`; however,
......@@ -104,8 +110,8 @@ class HashLocationStrategy extends LocationStrategy {
}
@override
Future<void> back() {
_platformLocation.back();
Future<void> back({int count = 1}) {
_platformLocation.back(count);
return _waitForPopState();
}
......@@ -142,10 +148,11 @@ abstract class PlatformLocation {
String get pathname;
String get search;
String? get hash;
dynamic get state;
void pushState(dynamic state, String title, String url);
void replaceState(dynamic state, String title, String url);
void back();
void back(int count);
}
/// An implementation of [PlatformLocation] for the browser.
......@@ -184,6 +191,9 @@ class BrowserPlatformLocation extends PlatformLocation {
@override
String get hash => _location.hash;
@override
dynamic get state => _history.state;
@override
void pushState(dynamic state, String title, String url) {
_history.pushState(state, title, url);
......@@ -195,7 +205,7 @@ class BrowserPlatformLocation extends PlatformLocation {
}
@override
void back() {
_history.back();
void back(int count) {
_history.go(-count);
}
}
......@@ -5,96 +5,264 @@
// @dart = 2.10
part of engine;
const MethodCall _popRouteMethodCall = MethodCall('popRoute');
Map<String, bool> _originState = <String, bool>{'origin': true};
Map<String, bool> _flutterState = <String, bool>{'flutter': true};
/// The origin entry is the history entry that the Flutter app landed on. It's
/// created by the browser when the user navigates to the url of the app.
bool _isOriginEntry(dynamic state) {
return state is Map && state['origin'] == true;
}
/// The flutter entry is a history entry that we maintain on top of the origin
/// entry. It allows us to catch popstate events when the user hits the back
/// button.
bool _isFlutterEntry(dynamic state) {
return state is Map && state['flutter'] == true;
}
/// The [BrowserHistory] class is responsible for integrating Flutter Web apps
/// with the browser history so that the back button works as expected.
/// An abstract class that provides the API for [EngineWindow] to delegate its
/// navigating events.
///
/// It does that by always keeping a single entry (conventionally called the
/// "flutter" entry) at the top of the browser history. That way, the browser's
/// back button always triggers a `popstate` event and never closes the app (we
/// close the app programmatically by calling [SystemNavigator.pop] when there
/// are no more app routes to be popped).
/// Subclasses will have access to [BrowserHistory.locationStrategy] to
/// interact with the html browser history and should come up with their own
/// ways to manage the states in the browser history.
///
/// There should only be one global instance of this class.
class BrowserHistory {
LocationStrategy? _locationStrategy;
ui.VoidCallback? _unsubscribe;
/// There should only be one global instance among all all subclasses.
///
/// See also:
///
/// * [SingleEntryBrowserHistory]: which creates a single fake browser history
/// entry and delegates all browser navigating events to the flutter
/// framework.
/// * [MultiEntriesBrowserHistory]: which creates a set of states that records
/// the navigating events happened in the framework.
abstract class BrowserHistory {
late ui.VoidCallback _unsubscribe;
/// Changing the location strategy will unsubscribe from the old strategy's
/// event listeners, and subscribe to the new one.
///
/// If the given [strategy] is the same as the existing one, nothing will
/// happen.
/// The strategy to interact with html browser history.
LocationStrategy? get locationStrategy => _locationStrategy;
LocationStrategy? _locationStrategy;
/// Updates the strategy.
///
/// If the given strategy is null, it will render this [BrowserHistory]
/// instance inactive.
set locationStrategy(LocationStrategy? strategy) {
/// This method will also remove any previous modifications to the html
/// browser history and start anew.
Future<void> setLocationStrategy(LocationStrategy? strategy) async {
if (strategy != _locationStrategy) {
_tearoffStrategy(_locationStrategy);
await _tearoffStrategy(_locationStrategy);
_locationStrategy = strategy;
_setupStrategy(_locationStrategy);
await _setupStrategy(_locationStrategy);
}
}
/// Returns the currently active location strategy.
@visibleForTesting
LocationStrategy? get locationStrategy => _locationStrategy;
Future<void> _setupStrategy(LocationStrategy? strategy) async {
if (strategy == null) {
return;
}
_unsubscribe = strategy.onPopState(onPopState as dynamic Function(html.Event));
await setup();
}
/// The path of the current location of the user's browser.
String get currentPath => _locationStrategy?.path ?? '/';
Future<void> _tearoffStrategy(LocationStrategy? strategy) async {
if (strategy == null) {
return;
}
_unsubscribe();
/// Update the url with the given [routeName].
void setRouteName(String? routeName) {
await tearDown();
}
/// Exit this application and return to the previous page.
Future<void> exit() async {
if (_locationStrategy != null) {
_setupFlutterEntry(_locationStrategy!, replace: true, path: routeName);
await _tearoffStrategy(_locationStrategy);
// Now the history should be in the original state, back one more time to
// exit the application.
await _locationStrategy!.back();
_locationStrategy = null;
}
}
/// This method does the same thing as the browser back button.
Future<void> back() {
if (_locationStrategy != null) {
return _locationStrategy!.back();
if (locationStrategy != null) {
return locationStrategy!.back();
}
return Future<void>.value();
}
/// This method exits the app and goes to whatever website was active before.
Future<void> exit() {
if (_locationStrategy != null) {
_tearoffStrategy(_locationStrategy);
// After tearing off the location strategy, we should be on the "origin"
// entry. So we need to go back one more time to exit the app.
final Future<void> backFuture = _locationStrategy!.back();
_locationStrategy = null;
return backFuture;
/// The path of the current location of the user's browser.
String get currentPath => locationStrategy?.path ?? '/';
/// The state of the current location of the user's browser.
dynamic get currentState => locationStrategy?.state;
/// Update the url with the given [routeName] and [state].
void setRouteName(String? routeName, {dynamic? state});
/// A callback method to handle browser backward or forward buttons.
///
/// Subclasses should send appropriate system messages to update the flutter
/// applications accordingly.
@protected
void onPopState(covariant html.PopStateEvent event);
/// Sets up any prerequisites to use this browser history class.
@protected
Future<void> setup() => Future<void>.value();
/// Restore any modifications to the html browser history during the lifetime
/// of this class.
@protected
Future<void> tearDown() => Future<void>.value();
}
/// A browser history class that creates a set of browser history entries to
/// support browser backward and forward button natively.
///
/// This class pushes a browser history entry every time the framework reports
/// a route change and sends a `pushRouteInformation` method call to the
/// framework when the browser jumps to a specific browser history entry.
///
/// The web engine uses this class to manage its browser history when the
/// framework uses a Router for routing.
///
/// See also:
///
/// * [SingleEntryBrowserHistory], which is used when the framework does not use
/// a Router for routing.
class MultiEntriesBrowserHistory extends BrowserHistory {
late int _lastSeenSerialCount;
int get _currentSerialCount {
if (_hasSerialCount(currentState)) {
return currentState['serialCount'] as int;
}
return 0;
}
dynamic _tagWithSerialCount(dynamic originialState, int count) {
return <dynamic, dynamic> {
'serialCount': count,
'state': originialState,
};
}
bool _hasSerialCount(dynamic state) {
return state is Map && state['serialCount'] != null;
}
@override
void setRouteName(String? routeName, {dynamic? state}) {
if (locationStrategy != null) {
assert(routeName != null);
_lastSeenSerialCount += 1;
locationStrategy!.pushState(
_tagWithSerialCount(state, _lastSeenSerialCount),
'flutter',
routeName!,
);
}
}
@override
void onPopState(covariant html.PopStateEvent event) {
assert(locationStrategy != null);
// May be a result of direct url access while the flutter application is
// already running.
if (!_hasSerialCount(event.state)) {
// In this case we assume this will be the next history entry from the
// last seen entry.
locationStrategy!.replaceState(
_tagWithSerialCount(event.state, _lastSeenSerialCount + 1),
'flutter',
currentPath);
}
_lastSeenSerialCount = _currentSerialCount;
if (window._onPlatformMessage != null) {
window.invokeOnPlatformMessage(
'flutter/navigation',
const JSONMethodCodec().encodeMethodCall(
MethodCall('pushRouteInformation', <dynamic, dynamic>{
'location': currentPath,
'state': event.state?['state'],
})
),
(_) {},
);
}
}
@override
Future<void> setup() {
if (!_hasSerialCount(currentState)) {
locationStrategy!.replaceState(
_tagWithSerialCount(currentState, 0),
'flutter',
currentPath
);
}
// If we retore from a page refresh, the _currentSerialCount may not be 0.
_lastSeenSerialCount = _currentSerialCount;
return Future<void>.value();
}
@override
Future<void> tearDown() async {
// Restores the html browser history.
assert(_hasSerialCount(currentState));
int backCount = _currentSerialCount;
if (backCount > 0) {
await locationStrategy!.back(count: backCount);
}
// Unwrap state.
assert(_hasSerialCount(currentState) && _currentSerialCount == 0);
locationStrategy!.replaceState(
currentState['state'],
'flutter',
currentPath,
);
}
}
/// The browser history class is responsible for integrating Flutter Web apps
/// with the browser history so that the back button works as expected.
///
/// It does that by always keeping a single entry (conventionally called the
/// "flutter" entry) at the top of the browser history. That way, the browser's
/// back button always triggers a `popstate` event and never closes the app (we
/// close the app programmatically by calling [SystemNavigator.pop] when there
/// are no more app routes to be popped).
///
/// The web engine uses this class when the framework does not use Router for
/// routing, and it does not support browser forward button.
///
/// See also:
///
/// * [MultiEntriesBrowserHistory], which is used when the framework uses a
/// Router for routing.
class SingleEntryBrowserHistory extends BrowserHistory {
static const MethodCall _popRouteMethodCall = MethodCall('popRoute');
static const String _kFlutterTag = 'flutter';
static const String _kOriginTag = 'origin';
Map<String, dynamic> _wrapOriginState(dynamic state) {
return <String, dynamic>{_kOriginTag: true, 'state': state};
}
dynamic _unwrapOriginState(dynamic state) {
assert(_isOriginEntry(state));
final Map<dynamic, dynamic> originState = state as Map<dynamic, dynamic>;
return originState['state'];
}
Map<String, bool> _flutterState = <String, bool>{_kFlutterTag: true};
/// The origin entry is the history entry that the Flutter app landed on. It's
/// created by the browser when the user navigates to the url of the app.
bool _isOriginEntry(dynamic state) {
return state is Map && state[_kOriginTag] == true;
}
/// The flutter entry is a history entry that we maintain on top of the origin
/// entry. It allows us to catch popstate events when the user hits the back
/// button.
bool _isFlutterEntry(dynamic state) {
return state is Map && state[_kFlutterTag] == true;
}
@override
void setRouteName(String? routeName, {dynamic? state}) {
if (locationStrategy != null) {
_setupFlutterEntry(locationStrategy!, replace: true, path: routeName);
}
}
String? _userProvidedRouteName;
void _popStateListener(covariant html.PopStateEvent event) {
@override
void onPopState(covariant html.PopStateEvent event) {
if (_isOriginEntry(event.state)) {
// If we find ourselves in the origin entry, it means that the user
// clicked the back button.
// 1. Re-push the flutter entry to keep it always at the top of history.
_setupFlutterEntry(_locationStrategy!);
// 2. Send a 'popRoute' platform message so the app can handle it accordingly.
......@@ -146,7 +314,7 @@ class BrowserHistory {
/// [_isOriginEntry] inside [_popStateListener].
void _setupOriginEntry(LocationStrategy strategy) {
assert(strategy != null); // ignore: unnecessary_null_comparison
strategy.replaceState(_originState, 'origin', '');
strategy.replaceState(_wrapOriginState(currentState), 'origin', '');
}
/// This method is used manipulate the Flutter Entry which is always the
......@@ -165,11 +333,8 @@ class BrowserHistory {
}
}
void _setupStrategy(LocationStrategy? strategy) {
if (strategy == null) {
return;
}
@override
Future<void> setup() {
final String path = currentPath;
if (_isFlutterEntry(html.window.history.state)) {
// This could happen if the user, for example, refreshes the page. They
......@@ -177,23 +342,19 @@ class BrowserHistory {
// the "origin" and "flutter" entries, we can safely assume they are
// already setup.
} else {
_setupOriginEntry(strategy);
_setupFlutterEntry(strategy, replace: false, path: path);
_setupOriginEntry(locationStrategy!);
_setupFlutterEntry(locationStrategy!, replace: false, path: path);
}
_unsubscribe = strategy.onPopState(_popStateListener as dynamic Function(html.Event));
return Future<void>.value();
}
void _tearoffStrategy(LocationStrategy? strategy) {
if (strategy == null) {
return;
@override
Future<void> tearDown() async {
if (locationStrategy != null) {
// We need to remove the flutter entry that we pushed in setup.
await locationStrategy!.back();
// Restores original state.
locationStrategy!.replaceState(_unwrapOriginState(currentState), 'flutter', currentPath);
}
assert(_unsubscribe != null);
_unsubscribe!();
_unsubscribe = null;
// Remove the "flutter" entry and go back to the "origin" entry so that the
// next location strategy can start from the right spot.
strategy.back();
}
}
......@@ -39,6 +39,11 @@ class TestLocationStrategy extends LocationStrategy {
@override
String get path => currentEntry.url;
@override
dynamic get state {
return currentEntry.state;
}
int _currentEntryIndex;
int get currentEntryIndex => _currentEntryIndex;
......@@ -100,12 +105,12 @@ class TestLocationStrategy extends LocationStrategy {
}
@override
Future<void> back() {
Future<void> back({int count = 1}) {
assert(withinAppHistory);
// Browsers don't move back in history immediately. They do it at the next
// event loop. So let's simulate that.
return _nextEventLoop(() {
_currentEntryIndex--;
_currentEntryIndex = _currentEntryIndex - count;
if (withinAppHistory) {
_firePopStateEvent();
}
......
......@@ -153,7 +153,45 @@ class EngineWindow extends ui.Window {
/// Handles the browser history integration to allow users to use the back
/// button, etc.
final BrowserHistory _browserHistory = BrowserHistory();
@visibleForTesting
BrowserHistory get browserHistory => _browserHistory;
BrowserHistory _browserHistory = MultiEntriesBrowserHistory();
@visibleForTesting
Future<void> debugSwitchBrowserHistory({required bool useSingle}) async {
if (useSingle)
await _useSingleEntryBrowserHistory();
else
await _useMultiEntryBrowserHistory();
}
/// This function should only be used for test setup. In real application, we
/// only allow one time switch from the MultiEntriesBrowserHistory to
/// the SingleEntryBrowserHistory to prevent the application to switch back
/// forth between router and non-router.
Future<void> _useMultiEntryBrowserHistory() async {
if (_browserHistory is MultiEntriesBrowserHistory) {
return;
}
final LocationStrategy? strategy = _browserHistory.locationStrategy;
if (strategy != null)
await _browserHistory.setLocationStrategy(null);
_browserHistory = MultiEntriesBrowserHistory();
if (strategy != null)
await _browserHistory.setLocationStrategy(strategy);
}
Future<void> _useSingleEntryBrowserHistory() async {
if (_browserHistory is SingleEntryBrowserHistory) {
return;
}
final LocationStrategy? strategy = _browserHistory.locationStrategy;
if (strategy != null)
await _browserHistory.setLocationStrategy(null);
_browserHistory = SingleEntryBrowserHistory();
if (strategy != null)
await _browserHistory.setLocationStrategy(strategy);
}
/// Simulates clicking the browser's back button.
Future<void> webOnlyBack() => _browserHistory.back();
......@@ -181,7 +219,7 @@ class EngineWindow extends ui.Window {
///
/// By setting this to null, the browser history will be disabled.
set locationStrategy(LocationStrategy? strategy) {
_browserHistory.locationStrategy = strategy;
_browserHistory.setLocationStrategy(strategy);
}
/// Returns the currently active location strategy.
......@@ -608,16 +646,20 @@ class EngineWindow extends ui.Window {
final Map<String, dynamic>? message = decoded.arguments;
switch (decoded.method) {
case 'routeUpdated':
case 'routePushed':
case 'routeReplaced':
_browserHistory.setRouteName(message!['routeName']);
_replyToPlatformMessage(
callback, codec.encodeSuccessEnvelope(true));
_useSingleEntryBrowserHistory().then((void data) {
_browserHistory.setRouteName(message!['routeName']);
_replyToPlatformMessage(
callback, codec.encodeSuccessEnvelope(true));
});
break;
case 'routePopped':
_browserHistory.setRouteName(message!['previousRouteName']);
case 'routeInformationUpdated':
assert(_browserHistory is MultiEntriesBrowserHistory);
_browserHistory.setRouteName(
message!['location'],
state: message!['state'],
);
_replyToPlatformMessage(
callback, codec.encodeSuccessEnvelope(true));
callback, codec.encodeSuccessEnvelope(true));
break;
}
// As soon as Flutter starts taking control of the app navigation, we
......
......@@ -16,10 +16,20 @@ import 'package:ui/src/engine.dart';
import '../spy.dart';
TestLocationStrategy _strategy;
TestLocationStrategy get strategy => _strategy;
set strategy(TestLocationStrategy newStrategy) {
window.locationStrategy = _strategy = newStrategy;
TestLocationStrategy get strategy => window.browserHistory.locationStrategy;
Future<void> setStrategy(TestLocationStrategy newStrategy) async {
await window.browserHistory.setLocationStrategy(newStrategy);
}
Map<String, dynamic> _wrapOriginState(dynamic state) {
return <String, dynamic>{'origin': true, 'state': state};
}
Map<String, dynamic> _tagStateWithSerialCount(dynamic state, int serialCount) {
return <String, dynamic> {
'serialCount': serialCount,
'state': state,
};
}
const Map<String, bool> originState = <String, bool>{'origin': true};
......@@ -34,28 +44,29 @@ void main() {
}
void testMain() {
group('$BrowserHistory', () {
group('$SingleEntryBrowserHistory', () {
final PlatformMessagesSpy spy = PlatformMessagesSpy();
setUp(() {
setUp(() async {
await window.debugSwitchBrowserHistory(useSingle: true);
spy.setUp();
});
tearDown(() {
tearDown(() async {
spy.tearDown();
strategy = null;
await setStrategy(null);
});
test('basic setup works', () {
strategy = TestLocationStrategy.fromEntry(
TestHistoryEntry('initial state', null, '/initial'));
test('basic setup works', () async {
await setStrategy(TestLocationStrategy.fromEntry(
TestHistoryEntry('initial state', null, '/initial')));
// There should be two entries: origin and flutter.
expect(strategy.history, hasLength(2));
// The origin entry is setup but its path should remain unchanged.
final TestHistoryEntry originEntry = strategy.history[0];
expect(originEntry.state, originState);
expect(originEntry.state, _wrapOriginState('initial state'));
expect(originEntry.url, '/initial');
// The flutter entry is pushed and its path should be derived from the
......@@ -71,15 +82,12 @@ void testMain() {
skip: browserEngine == BrowserEngine.edge);
test('browser back button pops routes correctly', () async {
strategy =
TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'));
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')));
// Initially, we should be on the flutter entry.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntry.state, flutterState);
expect(strategy.currentEntry.url, '/home');
pushRoute('/page1');
await routeUpdated('/page1');
// The number of entries shouldn't change.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
......@@ -107,11 +115,10 @@ void testMain() {
skip: browserEngine == BrowserEngine.edge);
test('multiple browser back clicks', () async {
strategy =
TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'));
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')));
pushRoute('/page1');
pushRoute('/page2');
await routeUpdated('/page1');
await routeUpdated('/page2');
// Make sure we are on page2.
expect(strategy.history, hasLength(2));
......@@ -128,7 +135,7 @@ void testMain() {
expect(spy.messages[0].methodArguments, isNull);
spy.messages.clear();
// 2. The framework sends a `routePopped` platform message.
popRoute('/page1');
await routeUpdated('/page1');
// 3. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
......@@ -144,15 +151,18 @@ void testMain() {
expect(spy.messages[0].methodArguments, isNull);
spy.messages.clear();
// 2. The framework sends a `routePopped` platform message.
popRoute('/home');
await routeUpdated('/home');
// 3. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, flutterState);
expect(strategy.currentEntry.url, '/home');
// The next browser back will exit the app.
await strategy.back();
// The next browser back will exit the app. We store the strategy locally
// because it will be remove from the browser history class once it exits
// the app.
TestLocationStrategy originalStrategy = strategy;
await originalStrategy.back();
// 1. The engine sends a `popRoute` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
......@@ -164,17 +174,16 @@ void testMain() {
await systemNavigatorPop();
// 3. The active entry doesn't belong to our history anymore because we
// navigated past it.
expect(strategy.currentEntryIndex, -1);
expect(originalStrategy.currentEntryIndex, -1);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge ||
browserEngine == BrowserEngine.webkit);
test('handle user-provided url', () async {
strategy =
TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'));
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')));
await _strategy.simulateUserTypingUrl('/page3');
await strategy.simulateUserTypingUrl('/page3');
// This delay is necessary to wait for [BrowserHistory] because it
// performs a `back` operation which results in a new event loop.
await Future<void>.delayed(Duration.zero);
......@@ -185,7 +194,7 @@ void testMain() {
expect(spy.messages[0].methodArguments, '/page3');
spy.messages.clear();
// 2. The framework sends a `routePushed` platform message.
pushRoute('/page3');
await routeUpdated('/page3');
// 3. The history state should reflect that /page3 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 1);
......@@ -201,7 +210,7 @@ void testMain() {
expect(spy.messages[0].methodArguments, isNull);
spy.messages.clear();
// 2. The framework sends a `routePopped` platform message.
popRoute('/home');
await routeUpdated('/home');
// 3. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
......@@ -212,10 +221,9 @@ void testMain() {
skip: browserEngine == BrowserEngine.edge);
test('user types unknown url', () async {
strategy =
TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'));
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')));
await _strategy.simulateUserTypingUrl('/unknown');
await strategy.simulateUserTypingUrl('/unknown');
// This delay is necessary to wait for [BrowserHistory] because it
// performs a `back` operation which results in a new event loop.
await Future<void>.delayed(Duration.zero);
......@@ -236,6 +244,212 @@ void testMain() {
skip: browserEngine == BrowserEngine.edge);
});
group('$MultiEntriesBrowserHistory', () {
final PlatformMessagesSpy spy = PlatformMessagesSpy();
setUp(() async {
await window.debugSwitchBrowserHistory(useSingle: false);
spy.setUp();
});
tearDown(() async {
spy.tearDown();
await setStrategy(null);
});
test('basic setup works', () async {
await setStrategy(TestLocationStrategy.fromEntry(
TestHistoryEntry('initial state', null, '/initial')));
// There should be only one entry.
expect(strategy.history, hasLength(1));
// The origin entry is tagged and its path should remain unchanged.
final TestHistoryEntry taggedOriginEntry = strategy.history[0];
expect(taggedOriginEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(taggedOriginEntry.url, '/initial');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('browser back button push route infromation correctly', () async {
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home')));
// Initially, we should be on the flutter entry.
expect(strategy.history, hasLength(1));
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
await routeInfomrationUpdated('/page1', 'page1 state');
// Should have two history entries now.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
// But the url of the current entry (flutter entry) should be updated.
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page1 state', 1));
expect(strategy.currentEntry.url, '/page1');
// No platform messages have been sent so far.
expect(spy.messages, isEmpty);
// Clicking back should take us to page1.
await strategy.back();
// First, the framework should've received a `pushRouteInformation`
// platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/home',
'state': 'initial state',
});
// There are still two browser history entries, but we are back to the
// original state.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 0);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('multiple browser back clicks', () async {
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home')));
await routeInfomrationUpdated('/page1', 'page1 state');
await routeInfomrationUpdated('/page2', 'page2 state');
// Make sure we are on page2.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 2);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page2 state', 2));
expect(strategy.currentEntry.url, '/page2');
// Back to page1.
await strategy.back();
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page1',
'state': 'page1 state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page1 state', 1));
expect(strategy.currentEntry.url, '/page1');
// Back to home.
await strategy.back();
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/home',
'state': 'initial state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 0);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge ||
browserEngine == BrowserEngine.webkit);
test('handle user-provided url', () async {
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home')));
await strategy.simulateUserTypingUrl('/page3');
// This delay is necessary to wait for [BrowserHistory] because it
// performs a `back` operation which results in a new event loop.
await Future<void>.delayed(Duration.zero);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page3',
'state': null,
});
spy.messages.clear();
// 2. The history state should reflect that /page3 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, _tagStateWithSerialCount(null, 1));
expect(strategy.currentEntry.url, '/page3');
// Back to home.
await strategy.back();
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/home',
'state': 'initial state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 0);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('forward button works', () async {
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home')));
await routeInfomrationUpdated('/page1', 'page1 state');
await routeInfomrationUpdated('/page2', 'page2 state');
// Make sure we are on page2.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 2);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page2 state', 2));
expect(strategy.currentEntry.url, '/page2');
// Back to page1.
await strategy.back();
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page1',
'state': 'page1 state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page1 state', 1));
expect(strategy.currentEntry.url, '/page1');
// Forward to page2
await strategy.back(count: -1);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page2',
'state': 'page2 state',
});
spy.messages.clear();
// 2. The history state should reflect that /page2 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 2);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page2 state', 2));
expect(strategy.currentEntry.url, '/page2');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
});
group('$HashLocationStrategy', () {
TestPlatformLocation location;
......@@ -272,40 +486,30 @@ void testMain() {
});
}
void pushRoute(String routeName) {
window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(MethodCall(
'routePushed',
<String, dynamic>{'previousRouteName': '/foo', 'routeName': routeName},
)),
emptyCallback,
);
}
void replaceRoute(String routeName) {
Future<void> routeUpdated(String routeName) {
final Completer<void> completer = Completer<void>();
window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(MethodCall(
'routeReplaced',
<String, dynamic>{'previousRouteName': '/foo', 'routeName': routeName},
'routeUpdated',
<String, dynamic>{'routeName': routeName},
)),
emptyCallback,
(_) => completer.complete(),
);
return completer.future;
}
void popRoute(String previousRouteName) {
Future<void> routeInfomrationUpdated(String location, dynamic state) {
final Completer<void> completer = Completer<void>();
window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(MethodCall(
'routePopped',
<String, dynamic>{
'previousRouteName': previousRouteName,
'routeName': '/foo'
},
'routeInformationUpdated',
<String, dynamic>{'location': location, 'state': state},
)),
emptyCallback,
(_) => completer.complete(),
);
return completer.future;
}
Future<void> systemNavigatorPop() {
......@@ -323,6 +527,7 @@ class TestPlatformLocation extends PlatformLocation {
String pathname;
String search;
String hash;
dynamic state;
void onPopState(html.EventListener fn) {
throw UnimplementedError();
......@@ -348,7 +553,7 @@ class TestPlatformLocation extends PlatformLocation {
throw UnimplementedError();
}
void back() {
void back(int count) {
throw UnimplementedError();
}
}
......@@ -3,6 +3,7 @@
// found in the LICENSE file.
// @dart = 2.6
import 'dart:async';
import 'dart:typed_data';
import 'package:test/bootstrap/browser.dart';
......@@ -28,68 +29,17 @@ void testMain() {
engine.window.locationStrategy = _strategy = null;
});
test('Tracks pushed, replaced and popped routes', () {
engine.window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(const engine.MethodCall(
'routePushed',
<String, dynamic>{'previousRouteName': '/', 'routeName': '/foo'},
)),
emptyCallback,
);
expect(_strategy.path, '/foo');
engine.window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(const engine.MethodCall(
'routePushed',
<String, dynamic>{'previousRouteName': '/foo', 'routeName': '/bar'},
)),
emptyCallback,
);
expect(_strategy.path, '/bar');
engine.window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(const engine.MethodCall(
'routePopped',
<String, dynamic>{'previousRouteName': '/foo', 'routeName': '/bar'},
)),
emptyCallback,
);
expect(_strategy.path, '/foo');
engine.window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(const engine.MethodCall(
'routePushed',
<String, dynamic>{'previousRouteName': '/foo', 'routeName': '/bar/baz'},
)),
emptyCallback,
);
expect(_strategy.path, '/bar/baz');
engine.window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(const engine.MethodCall(
'routeReplaced',
<String, dynamic>{
'previousRouteName': '/bar/baz',
'routeName': '/bar/baz2',
},
)),
emptyCallback,
);
expect(_strategy.path, '/bar/baz2');
test('Tracks pushed, replaced and popped routes', () async {
final Completer<void> completer = Completer<void>();
engine.window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(const engine.MethodCall(
'routeUpdated',
<String, dynamic>{'previousRouteName': '/bar/baz2', 'routeName': '/foo/foo/2'},
<String, dynamic>{'routeName': '/foo'},
)),
emptyCallback,
(_) => completer.complete(),
);
expect(_strategy.path, '/foo/foo/2');
await completer.future;
expect(_strategy.path, '/foo');
});
}
......@@ -16,13 +16,21 @@ const MethodCodec codec = JSONMethodCodec();
void emptyCallback(ByteData date) {}
Future<void> setStrategy(TestLocationStrategy newStrategy) async {
await window.browserHistory.setLocationStrategy(newStrategy);
}
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
test('window.defaultRouteName should not change', () {
window.locationStrategy = TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/initial'));
setUp(() async {
await window.debugSwitchBrowserHistory(useSingle: true);
});
test('window.defaultRouteName should not change', () async {
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/initial')));
expect(window.defaultRouteName, '/initial');
// Changing the URL in the address bar later shouldn't affect [window.defaultRouteName].
......@@ -30,17 +38,16 @@ void testMain() {
expect(window.defaultRouteName, '/initial');
});
test('window.defaultRouteName should reset after navigation platform message', () {
window.locationStrategy = TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/initial'));
test('window.defaultRouteName should reset after navigation platform message', () async {
await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/initial')));
// Reading it multiple times should return the same value.
expect(window.defaultRouteName, '/initial');
expect(window.defaultRouteName, '/initial');
window.sendPlatformMessage(
'flutter/navigation',
JSONMethodCodec().encodeMethodCall(MethodCall(
'routePushed',
<String, dynamic>{'previousRouteName': '/foo', 'routeName': '/bar'},
'routeUpdated',
<String, dynamic>{'routeName': '/bar'},
)),
emptyCallback,
);
......@@ -50,22 +57,25 @@ void testMain() {
});
test('can disable location strategy', () async {
await window.debugSwitchBrowserHistory(useSingle: true);
final testStrategy = TestLocationStrategy.fromEntry(
TestHistoryEntry(null, null, '/'),
TestHistoryEntry('initial state', null, '/'),
);
window.locationStrategy = testStrategy;
await setStrategy(testStrategy);
expect(window.locationStrategy, testStrategy);
// A single listener should've been setup.
expect(testStrategy.listeners, hasLength(1));
// The initial entry should be there, plus another "flutter" entry.
expect(testStrategy.history, hasLength(2));
expect(testStrategy.history[0].state, <String, bool>{'origin': true});
expect(testStrategy.history[0].state, <String, dynamic>{'origin': true, 'state': 'initial state'});
expect(testStrategy.history[1].state, <String, bool>{'flutter': true});
expect(testStrategy.currentEntry, testStrategy.history[1]);
// Now, let's disable location strategy and make sure things get cleaned up.
expect(() => jsSetLocationStrategy(null), returnsNormally);
// The locationStrategy is teared down asynchronously.
await Future<void>.delayed(Duration.zero);
expect(window.locationStrategy, isNull);
// The listener is removed asynchronously.
......@@ -73,8 +83,8 @@ void testMain() {
// No more listeners.
expect(testStrategy.listeners, isEmpty);
// History should've moved back to the initial entry.
expect(testStrategy.history[0].state, <String, bool>{'origin': true});
// History should've moved back to the initial state.
expect(testStrategy.history[0].state, "initial state");
expect(testStrategy.currentEntry, testStrategy.history[0]);
});
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册