From da3c08e3ba5c8116e2e08af5f5aaa917bbbcc50c Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 10 Sep 2020 21:00:02 -0700 Subject: [PATCH] Implement browser history class for router widget (flutter/engine#20794) --- .../lib/src/engine/browser_location.dart | 22 +- .../lib/web_ui/lib/src/engine/history.dart | 333 +++++++++++++----- .../web_ui/lib/src/engine/test_embedding.dart | 9 +- .../lib/web_ui/lib/src/engine/window.dart | 62 +++- .../lib/web_ui/test/engine/history_test.dart | 319 ++++++++++++++--- .../web_ui/test/engine/navigation_test.dart | 64 +--- .../flutter/lib/web_ui/test/window_test.dart | 34 +- 7 files changed, 613 insertions(+), 230 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/browser_location.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/browser_location.dart index 2f92a3efae4..a9701cd9906 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/browser_location.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/browser_location.dart @@ -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 back(); + Future 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 back() { - _platformLocation.back(); + Future 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); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/history.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/history.dart index 96e8b55a68b..75ee90ace28 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/history.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/history.dart @@ -5,25 +5,210 @@ // @dart = 2.10 part of engine; -const MethodCall _popRouteMethodCall = MethodCall('popRoute'); +/// An abstract class that provides the API for [EngineWindow] to delegate its +/// navigating events. +/// +/// 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 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; -Map _originState = {'origin': true}; -Map _flutterState = {'flutter': true}; + /// The strategy to interact with html browser history. + LocationStrategy? get locationStrategy => _locationStrategy; + LocationStrategy? _locationStrategy; + /// Updates the strategy. + /// + /// This method will also remove any previous modifications to the html + /// browser history and start anew. + Future setLocationStrategy(LocationStrategy? strategy) async { + if (strategy != _locationStrategy) { + await _tearoffStrategy(_locationStrategy); + _locationStrategy = strategy; + await _setupStrategy(_locationStrategy); + } + } -/// 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; + Future _setupStrategy(LocationStrategy? strategy) async { + if (strategy == null) { + return; + } + _unsubscribe = strategy.onPopState(onPopState as dynamic Function(html.Event)); + await setup(); + } + + Future _tearoffStrategy(LocationStrategy? strategy) async { + if (strategy == null) { + return; + } + _unsubscribe(); + + await tearDown(); + } + + /// Exit this application and return to the previous page. + Future exit() async { + if (_locationStrategy != null) { + 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 back() { + if (locationStrategy != null) { + return locationStrategy!.back(); + } + return Future.value(); + } + + /// 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 setup() => Future.value(); + + /// Restore any modifications to the html browser history during the lifetime + /// of this class. + @protected + Future tearDown() => Future.value(); } -/// 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; +/// 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 { + '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', { + 'location': currentPath, + 'state': event.state?['state'], + }) + ), + (_) {}, + ); + } + } + + @override + Future 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.value(); + } + + @override + Future 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 [BrowserHistory] class is responsible for integrating Flutter Web apps +/// 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 @@ -32,69 +217,52 @@ bool _isFlutterEntry(dynamic state) { /// close the app programmatically by calling [SystemNavigator.pop] when there /// are no more app routes to be popped). /// -/// There should only be one global instance of this class. -class BrowserHistory { - LocationStrategy? _locationStrategy; - ui.VoidCallback? _unsubscribe; +/// 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'; - /// 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. - /// - /// If the given strategy is null, it will render this [BrowserHistory] - /// instance inactive. - set locationStrategy(LocationStrategy? strategy) { - if (strategy != _locationStrategy) { - _tearoffStrategy(_locationStrategy); - _locationStrategy = strategy; - _setupStrategy(_locationStrategy); - } + Map _wrapOriginState(dynamic state) { + return {_kOriginTag: true, 'state': state}; + } + dynamic _unwrapOriginState(dynamic state) { + assert(_isOriginEntry(state)); + final Map originState = state as Map; + return originState['state']; + } + Map _flutterState = {_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; } - /// Returns the currently active location strategy. - @visibleForTesting - LocationStrategy? get locationStrategy => _locationStrategy; - - /// The path of the current location of the user's browser. - String get currentPath => _locationStrategy?.path ?? '/'; - - /// Update the url with the given [routeName]. - void setRouteName(String? routeName) { - if (_locationStrategy != null) { - _setupFlutterEntry(_locationStrategy!, replace: true, path: routeName); - } + /// 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; } - /// This method does the same thing as the browser back button. - Future back() { - if (_locationStrategy != null) { - return _locationStrategy!.back(); + @override + void setRouteName(String? routeName, {dynamic? state}) { + if (locationStrategy != null) { + _setupFlutterEntry(locationStrategy!, replace: true, path: routeName); } - return Future.value(); - } - - /// This method exits the app and goes to whatever website was active before. - Future 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 backFuture = _locationStrategy!.back(); - _locationStrategy = null; - return backFuture; - } - return Future.value(); } 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 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.value(); } - void _tearoffStrategy(LocationStrategy? strategy) { - if (strategy == null) { - return; + @override + Future 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(); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/test_embedding.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/test_embedding.dart index 23faf15de7d..f0d3a4291db 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/test_embedding.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/test_embedding.dart @@ -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 back() { + Future 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(); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart index f554d8232a7..94ea5fd2597 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/window.dart @@ -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 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 _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 _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 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? 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 diff --git a/engine/src/flutter/lib/web_ui/test/engine/history_test.dart b/engine/src/flutter/lib/web_ui/test/engine/history_test.dart index 4d621117a63..4c11ed00336 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/history_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/history_test.dart @@ -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 setStrategy(TestLocationStrategy newStrategy) async { + await window.browserHistory.setLocationStrategy(newStrategy); +} + +Map _wrapOriginState(dynamic state) { + return {'origin': true, 'state': state}; +} + +Map _tagStateWithSerialCount(dynamic state, int serialCount) { + return { + 'serialCount': serialCount, + 'state': state, + }; } const Map originState = {'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.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.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, { + '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, { + '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, { + '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.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, { + '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, { + '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, { + '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, { + '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) { +Future routeUpdated(String routeName) { + final Completer completer = Completer(); window.sendPlatformMessage( 'flutter/navigation', codec.encodeMethodCall(MethodCall( - 'routePushed', - {'previousRouteName': '/foo', 'routeName': routeName}, + 'routeUpdated', + {'routeName': routeName}, )), - emptyCallback, + (_) => completer.complete(), ); + return completer.future; } -void replaceRoute(String routeName) { +Future routeInfomrationUpdated(String location, dynamic state) { + final Completer completer = Completer(); window.sendPlatformMessage( 'flutter/navigation', codec.encodeMethodCall(MethodCall( - 'routeReplaced', - {'previousRouteName': '/foo', 'routeName': routeName}, + 'routeInformationUpdated', + {'location': location, 'state': state}, )), - emptyCallback, - ); -} - -void popRoute(String previousRouteName) { - window.sendPlatformMessage( - 'flutter/navigation', - codec.encodeMethodCall(MethodCall( - 'routePopped', - { - 'previousRouteName': previousRouteName, - 'routeName': '/foo' - }, - )), - emptyCallback, + (_) => completer.complete(), ); + return completer.future; } Future 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(); } } diff --git a/engine/src/flutter/lib/web_ui/test/engine/navigation_test.dart b/engine/src/flutter/lib/web_ui/test/engine/navigation_test.dart index d4ac941d3de..44d3bf2939e 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/navigation_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/navigation_test.dart @@ -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', - {'previousRouteName': '/', 'routeName': '/foo'}, - )), - emptyCallback, - ); - expect(_strategy.path, '/foo'); - - engine.window.sendPlatformMessage( - 'flutter/navigation', - codec.encodeMethodCall(const engine.MethodCall( - 'routePushed', - {'previousRouteName': '/foo', 'routeName': '/bar'}, - )), - emptyCallback, - ); - expect(_strategy.path, '/bar'); - - engine.window.sendPlatformMessage( - 'flutter/navigation', - codec.encodeMethodCall(const engine.MethodCall( - 'routePopped', - {'previousRouteName': '/foo', 'routeName': '/bar'}, - )), - emptyCallback, - ); - expect(_strategy.path, '/foo'); - - engine.window.sendPlatformMessage( - 'flutter/navigation', - codec.encodeMethodCall(const engine.MethodCall( - 'routePushed', - {'previousRouteName': '/foo', 'routeName': '/bar/baz'}, - )), - emptyCallback, - ); - expect(_strategy.path, '/bar/baz'); - - engine.window.sendPlatformMessage( - 'flutter/navigation', - codec.encodeMethodCall(const engine.MethodCall( - 'routeReplaced', - { - 'previousRouteName': '/bar/baz', - 'routeName': '/bar/baz2', - }, - )), - emptyCallback, - ); - expect(_strategy.path, '/bar/baz2'); - + test('Tracks pushed, replaced and popped routes', () async { + final Completer completer = Completer(); engine.window.sendPlatformMessage( 'flutter/navigation', codec.encodeMethodCall(const engine.MethodCall( 'routeUpdated', - {'previousRouteName': '/bar/baz2', 'routeName': '/foo/foo/2'}, + {'routeName': '/foo'}, )), - emptyCallback, + (_) => completer.complete(), ); - expect(_strategy.path, '/foo/foo/2'); + await completer.future; + expect(_strategy.path, '/foo'); }); } diff --git a/engine/src/flutter/lib/web_ui/test/window_test.dart b/engine/src/flutter/lib/web_ui/test/window_test.dart index 44894d8bc8d..b83849bffc8 100644 --- a/engine/src/flutter/lib/web_ui/test/window_test.dart +++ b/engine/src/flutter/lib/web_ui/test/window_test.dart @@ -16,13 +16,21 @@ const MethodCodec codec = JSONMethodCodec(); void emptyCallback(ByteData date) {} +Future 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', - {'previousRouteName': '/foo', 'routeName': '/bar'}, + 'routeUpdated', + {'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, {'origin': true}); + expect(testStrategy.history[0].state, {'origin': true, 'state': 'initial state'}); expect(testStrategy.history[1].state, {'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.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, {'origin': true}); + // History should've moved back to the initial state. + expect(testStrategy.history[0].state, "initial state"); expect(testStrategy.currentEntry, testStrategy.history[0]); });