From 7266e0ad8ce9811e359155042de18661d5af6bdd Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 17 Jun 2020 16:10:08 -0700 Subject: [PATCH 1/7] [web] Support custom location strategies --- lib/web_ui/lib/src/engine.dart | 5 +- .../lib/src/engine/{ => history}/history.dart | 70 +++++----- .../src/engine/history/js_url_strategy.dart | 84 ++++++++++++ .../url_strategy.dart} | 123 ++++++++++------- lib/web_ui/lib/src/engine/test_embedding.dart | 24 ++-- lib/web_ui/lib/src/engine/window.dart | 128 ++++++++++++------ lib/web_ui/lib/src/ui/initialization.dart | 4 - lib/web_ui/test/engine/history_test.dart | 87 ++++++------ lib/web_ui/test/engine/navigation_test.dart | 14 +- lib/web_ui/test/window_test.dart | 48 ++++--- 10 files changed, 372 insertions(+), 215 deletions(-) rename lib/web_ui/lib/src/engine/{ => history}/history.dart (86%) create mode 100644 lib/web_ui/lib/src/engine/history/js_url_strategy.dart rename lib/web_ui/lib/src/engine/{browser_location.dart => history/url_strategy.dart} (61%) diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index d8c01373840f8..21be03c6ac117 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -26,7 +26,6 @@ part 'engine/alarm_clock.dart'; part 'engine/assets.dart'; part 'engine/bitmap_canvas.dart'; part 'engine/browser_detection.dart'; -part 'engine/browser_location.dart'; part 'engine/canvaskit/canvas.dart'; part 'engine/canvaskit/canvaskit_canvas.dart'; part 'engine/canvaskit/canvaskit_api.dart'; @@ -63,7 +62,9 @@ part 'engine/dom_canvas.dart'; part 'engine/dom_renderer.dart'; part 'engine/engine_canvas.dart'; part 'engine/frame_reference.dart'; -part 'engine/history.dart'; +part 'engine/history/history.dart'; +part 'engine/history/js_url_strategy.dart'; +part 'engine/history/url_strategy.dart'; part 'engine/html/backdrop_filter.dart'; part 'engine/html/canvas.dart'; part 'engine/html/clip.dart'; diff --git a/lib/web_ui/lib/src/engine/history.dart b/lib/web_ui/lib/src/engine/history/history.dart similarity index 86% rename from lib/web_ui/lib/src/engine/history.dart rename to lib/web_ui/lib/src/engine/history/history.dart index 59e1ba5fddf6d..beddf4f4fb7e6 100644 --- a/lib/web_ui/lib/src/engine/history.dart +++ b/lib/web_ui/lib/src/engine/history/history.dart @@ -25,21 +25,21 @@ abstract class BrowserHistory { late ui.VoidCallback _unsubscribe; /// The strategy to interact with html browser history. - LocationStrategy? get locationStrategy => _locationStrategy; - LocationStrategy? _locationStrategy; + JsUrlStrategy? get urlStrategy => _urlStrategy; + JsUrlStrategy? _urlStrategy; /// 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); + Future setUrlStrategy(JsUrlStrategy? strategy) async { + if (strategy != _urlStrategy) { + await _tearoffStrategy(_urlStrategy); + _urlStrategy = strategy; + await _setupStrategy(_urlStrategy); } } - Future _setupStrategy(LocationStrategy? strategy) async { + Future _setupStrategy(JsUrlStrategy? strategy) async { if (strategy == null) { return; } @@ -47,7 +47,7 @@ abstract class BrowserHistory { await setup(); } - Future _tearoffStrategy(LocationStrategy? strategy) async { + Future _tearoffStrategy(JsUrlStrategy? strategy) async { if (strategy == null) { return; } @@ -58,28 +58,28 @@ abstract class BrowserHistory { /// Exit this application and return to the previous page. Future exit() async { - if (_locationStrategy != null) { - await _tearoffStrategy(_locationStrategy); + if (_urlStrategy != null) { + await _tearoffStrategy(_urlStrategy); // Now the history should be in the original state, back one more time to // exit the application. - await _locationStrategy!.back(); - _locationStrategy = null; + await _urlStrategy!.go(-1); + _urlStrategy = null; } } /// This method does the same thing as the browser back button. Future back() { - if (locationStrategy != null) { - return locationStrategy!.back(); + if (_urlStrategy != null) { + return _urlStrategy!.go(-1); } return Future.value(); } /// The path of the current location of the user's browser. - String get currentPath => locationStrategy?.path ?? '/'; + String get currentPath => urlStrategy?.getPath() ?? '/'; /// The state of the current location of the user's browser. - dynamic get currentState => locationStrategy?.state; + dynamic get currentState => urlStrategy?.getState(); /// Update the url with the given [routeName] and [state]. void setRouteName(String? routeName, {dynamic? state}); @@ -134,10 +134,10 @@ class MultiEntriesBrowserHistory extends BrowserHistory { @override void setRouteName(String? routeName, {dynamic? state}) { - if (locationStrategy != null) { + if (urlStrategy != null) { assert(routeName != null); _lastSeenSerialCount += 1; - locationStrategy!.pushState( + urlStrategy!.pushState( _tagWithSerialCount(state, _lastSeenSerialCount), 'flutter', routeName!, @@ -147,13 +147,13 @@ class MultiEntriesBrowserHistory extends BrowserHistory { @override void onPopState(covariant html.PopStateEvent event) { - assert(locationStrategy != null); + assert(urlStrategy != 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( + urlStrategy!.replaceState( _tagWithSerialCount(event.state, _lastSeenSerialCount + 1), 'flutter', currentPath); @@ -176,7 +176,7 @@ class MultiEntriesBrowserHistory extends BrowserHistory { @override Future setup() { if (!_hasSerialCount(currentState)) { - locationStrategy!.replaceState( + urlStrategy!.replaceState( _tagWithSerialCount(currentState, 0), 'flutter', currentPath @@ -193,11 +193,11 @@ class MultiEntriesBrowserHistory extends BrowserHistory { assert(_hasSerialCount(currentState)); int backCount = _currentSerialCount; if (backCount > 0) { - await locationStrategy!.back(count: backCount); + await urlStrategy!.go(-backCount); } // Unwrap state. assert(_hasSerialCount(currentState) && _currentSerialCount == 0); - locationStrategy!.replaceState( + urlStrategy!.replaceState( currentState['state'], 'flutter', currentPath, @@ -251,8 +251,8 @@ class SingleEntryBrowserHistory extends BrowserHistory { @override void setRouteName(String? routeName, {dynamic? state}) { - if (locationStrategy != null) { - _setupFlutterEntry(locationStrategy!, replace: true, path: routeName); + if (urlStrategy != null) { + _setupFlutterEntry(urlStrategy!, replace: true, path: routeName); } } @@ -260,7 +260,7 @@ class SingleEntryBrowserHistory extends BrowserHistory { @override void onPopState(covariant html.PopStateEvent event) { if (_isOriginEntry(event.state)) { - _setupFlutterEntry(_locationStrategy!); + _setupFlutterEntry(_urlStrategy!); // 2. Send a 'popRoute' platform message so the app can handle it accordingly. if (window._onPlatformMessage != null) { @@ -302,14 +302,14 @@ class SingleEntryBrowserHistory extends BrowserHistory { // 2. Then we remove the new entry. // This will take us back to our "flutter" entry and it causes a new // popstate event that will be handled in the "else if" section above. - _locationStrategy!.back(); + _urlStrategy!.go(-1); } } /// This method should be called when the Origin Entry is active. It just /// replaces the state of the entry so that we can recognize it later using /// [_isOriginEntry] inside [_popStateListener]. - void _setupOriginEntry(LocationStrategy strategy) { + void _setupOriginEntry(JsUrlStrategy strategy) { assert(strategy != null); // ignore: unnecessary_null_comparison strategy.replaceState(_wrapOriginState(currentState), 'origin', ''); } @@ -317,7 +317,7 @@ class SingleEntryBrowserHistory extends BrowserHistory { /// This method is used manipulate the Flutter Entry which is always the /// active entry while the Flutter app is running. void _setupFlutterEntry( - LocationStrategy strategy, { + JsUrlStrategy strategy, { bool replace = false, String? path, }) { @@ -339,19 +339,19 @@ class SingleEntryBrowserHistory extends BrowserHistory { // the "origin" and "flutter" entries, we can safely assume they are // already setup. } else { - _setupOriginEntry(locationStrategy!); - _setupFlutterEntry(locationStrategy!, replace: false, path: path); + _setupOriginEntry(urlStrategy!); + _setupFlutterEntry(urlStrategy!, replace: false, path: path); } return Future.value(); } @override Future tearDown() async { - if (locationStrategy != null) { + if (urlStrategy != null) { // We need to remove the flutter entry that we pushed in setup. - await locationStrategy!.back(); + await urlStrategy!.go(-1); // Restores original state. - locationStrategy!.replaceState(_unwrapOriginState(currentState), 'flutter', currentPath); + urlStrategy!.replaceState(_unwrapOriginState(currentState), 'flutter', currentPath); } } } diff --git a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart b/lib/web_ui/lib/src/engine/history/js_url_strategy.dart new file mode 100644 index 0000000000000..96afffcfd731a --- /dev/null +++ b/lib/web_ui/lib/src/engine/history/js_url_strategy.dart @@ -0,0 +1,84 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.10 +part of engine; + +typedef _PathGetter = String Function(); + +typedef _StateGetter = dynamic Function(); + +typedef _OnPopState = ui.VoidCallback Function(html.EventListener); + +typedef _StringToString = String Function(String); + +typedef _StateOperation = void Function( + dynamic state, String title, String url); + +typedef _HistoryMove = Future Function(int count); + +/// Given a Dart implementation of URL strategy, it converts it to a JavaScript +/// URL strategy that be passed through JS interop. +JsUrlStrategy? convertToJsUrlStrategy(UrlStrategy? strategy) { + if (strategy == null) { + return null; + } + + return JsUrlStrategy( + getPath: allowInterop(strategy.getPath), + getState: allowInterop(strategy.getState), + onPopState: allowInterop(strategy.onPopState), + prepareExternalUrl: allowInterop(strategy.prepareExternalUrl), + pushState: allowInterop(strategy.pushState), + replaceState: allowInterop(strategy.replaceState), + go: allowInterop(strategy.go), + // getBaseHref: allowInterop(strategy.getBaseHref), + ); +} + +/// The JavaScript representation of a URL strategy. +/// +/// This is used to pass URL strategy implementations across a JS-interop +/// bridge. +@JS() +@anonymous +abstract class JsUrlStrategy { + /// Creates an instance of [JsUrlStrategy] from a bag of URL strategy + /// functions. + external factory JsUrlStrategy({ + @required _PathGetter getPath, + @required _StateGetter getState, + @required _OnPopState onPopState, + @required _StringToString prepareExternalUrl, + @required _StateOperation pushState, + @required _StateOperation replaceState, + @required _HistoryMove go, + }); + + /// Subscribes to popstate events and returns a function that could be used to + /// unsubscribe from popstate events. + external ui.VoidCallback onPopState(html.EventListener fn); + + /// Returns the active path in the browser. + external String getPath(); + + /// Returns the history state in the browser. + external dynamic getState(); + + /// Given a path that's internal to the app, create the external url that + /// will be used in the browser. + external String prepareExternalUrl(String internalUrl); + + /// Push a new history entry. + external void pushState(dynamic state, String title, String url); + + /// Replace the currently active history entry. + external void replaceState(dynamic state, String title, String url); + + /// Moves forwards or backwards through the history stack. + external Future go(int count); + + // TODO: add this: + // external String getBaseHref(); +} diff --git a/lib/web_ui/lib/src/engine/browser_location.dart b/lib/web_ui/lib/src/engine/history/url_strategy.dart similarity index 61% rename from lib/web_ui/lib/src/engine/browser_location.dart rename to lib/web_ui/lib/src/engine/history/url_strategy.dart index a9701cd99060f..b88b961094af2 100644 --- a/lib/web_ui/lib/src/engine/browser_location.dart +++ b/lib/web_ui/lib/src/engine/history/url_strategy.dart @@ -5,28 +5,25 @@ // @dart = 2.10 part of engine; -// TODO(mdebbar): add other strategies. - -// Some parts of this file were inspired/copied from the AngularDart router. - -/// [LocationStrategy] is responsible for representing and reading route state +/// [UrlStrategy] is responsible for representing and reading route state /// from the browser's URL. /// -/// At the moment, only one strategy is implemented: [HashLocationStrategy]. +/// At the moment, only one strategy is implemented: [HashUrlStrategy]. /// /// This is used by [BrowserHistory] to interact with browser history APIs. -abstract class LocationStrategy { - const LocationStrategy(); +abstract class UrlStrategy { + /// This constructor is here only to allow subclasses to be const. + const UrlStrategy(); /// Subscribes to popstate events and returns a function that could be used to /// unsubscribe from popstate events. ui.VoidCallback onPopState(html.EventListener fn); - /// The active path in the browser history. - String get path; + /// Returns the active path in the browser. + String getPath(); /// The state of the current browser history entry. - dynamic get state; + dynamic getState(); /// Given a path that's internal to the app, create the external url that /// will be used in the browser. @@ -38,32 +35,32 @@ abstract class LocationStrategy { /// Replace the currently active history entry. void replaceState(dynamic state, String title, String url); - /// Go to the previous history entry. - Future back({int count = 1}); + /// Moves forwards or backwards through the history stack. + Future go(int count); } -/// This is an implementation of [LocationStrategy] that uses the browser URL's +/// This is an implementation of [UrlStrategy] that uses the browser URL's /// [hash fragments](https://en.wikipedia.org/wiki/Uniform_Resource_Locator#Syntax) /// to represent its state. /// -/// In order to use this [LocationStrategy] for an app, it needs to be set in -/// [ui.window.locationStrategy]: +/// In order to use this [UrlStrategy] for an app, it needs to be set like this: /// /// ```dart -/// import 'package:flutter_web/material.dart'; -/// import 'package:flutter_web/ui.dart' as ui; +/// import 'package:flutter_web_plugins/flutter_web_plugins.dart'; /// -/// void main() { -/// ui.window.locationStrategy = const ui.HashLocationStrategy(); -/// runApp(MyApp()); -/// } +/// // Somewhere before calling `runApp()` do: +/// setUrlStrategy(const HashUrlStrategy()); /// ``` -class HashLocationStrategy extends LocationStrategy { - final PlatformLocation _platformLocation; - - const HashLocationStrategy( +class HashUrlStrategy extends UrlStrategy { + /// Creates an instance of [HashUrlStrategy]. + /// + /// The [PlatformLocation] parameter is useful for testing to avoid + /// interacting with the actual browser. + const HashUrlStrategy( [this._platformLocation = const BrowserPlatformLocation()]); + final PlatformLocation _platformLocation; + @override ui.VoidCallback onPopState(html.EventListener fn) { _platformLocation.onPopState(fn); @@ -71,10 +68,10 @@ class HashLocationStrategy extends LocationStrategy { } @override - String get path { + String getPath() { // the hash value is always prefixed with a `#` // and if it is empty then it will stay empty - String path = _platformLocation.hash ?? ''; + final String path = _platformLocation.hash ?? ''; assert(path.isEmpty || path.startsWith('#')); // We don't want to return an empty string as a path. Instead we default to "/". @@ -86,7 +83,7 @@ class HashLocationStrategy extends LocationStrategy { } @override - dynamic get state => _platformLocation.state; + dynamic getState() => _platformLocation.state; @override String prepareExternalUrl(String internalUrl) { @@ -110,8 +107,8 @@ class HashLocationStrategy extends LocationStrategy { } @override - Future back({int count = 1}) { - _platformLocation.back(count); + Future go(int count) { + _platformLocation.go(count); return _waitForPopState(); } @@ -131,37 +128,71 @@ class HashLocationStrategy extends LocationStrategy { } /// [PlatformLocation] encapsulates all calls to DOM apis, which allows the -/// [LocationStrategy] classes to be platform agnostic and testable. +/// [UrlStrategy] classes to be platform agnostic and testable. /// /// The [PlatformLocation] class is used directly by all implementations of -/// [LocationStrategy] when they need to interact with the DOM apis like +/// [UrlStrategy] when they need to interact with the DOM apis like /// pushState, popState, etc... abstract class PlatformLocation { + /// const PlatformLocation(); + /// Registers an event listener for the `onpopstate` event. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate void onPopState(html.EventListener fn); - void offPopState(html.EventListener fn); - void onHashChange(html.EventListener fn); - void offHashChange(html.EventListener fn); + /// Unregisters the given listener from the`popstate` event. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate + void offPopState(html.EventListener fn); + /// The [pathname](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname) + /// part of the URL in the browser address bar. String get pathname; + + /// The `query` part of the URL in the browser address bar. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/Location/search String get search; + + /// The `hash]` part of the URL in the browser address bar. + /// + /// See: ttps://developer.mozilla.org/en-US/docs/Web/API/Location/hash String? get hash; + + /// The `state` in the current history entry. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/state dynamic get state; + /// Adds a new entry to the browser history stack. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState void pushState(dynamic state, String title, String url); + + /// Replaces the current entry in the browser history stack. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState void replaceState(dynamic state, String title, String url); - void back(int count); + + /// Moves forwards or backwards through the history stack. + /// + /// A negative [count] moves backwards, while a positive [count] moves + /// forwards. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/go + void go(int count); } /// An implementation of [PlatformLocation] for the browser. class BrowserPlatformLocation extends PlatformLocation { + /// + const BrowserPlatformLocation(); + html.Location get _location => html.window.location; html.History get _history => html.window.history; - const BrowserPlatformLocation(); - @override void onPopState(html.EventListener fn) { html.window.addEventListener('popstate', fn); @@ -172,16 +203,6 @@ class BrowserPlatformLocation extends PlatformLocation { html.window.removeEventListener('popstate', fn); } - @override - void onHashChange(html.EventListener fn) { - html.window.addEventListener('hashchange', fn); - } - - @override - void offHashChange(html.EventListener fn) { - html.window.removeEventListener('hashchange', fn); - } - @override String get pathname => _location.pathname!; @@ -205,7 +226,7 @@ class BrowserPlatformLocation extends PlatformLocation { } @override - void back(int count) { - _history.go(-count); + void go(int count) { + _history.go(count); } } diff --git a/lib/web_ui/lib/src/engine/test_embedding.dart b/lib/web_ui/lib/src/engine/test_embedding.dart index f0d3a4291dbad..cb231596fba5f 100644 --- a/lib/web_ui/lib/src/engine/test_embedding.dart +++ b/lib/web_ui/lib/src/engine/test_embedding.dart @@ -20,29 +20,27 @@ class TestHistoryEntry { } } -/// This location strategy mimics the browser's history as closely as possible +/// This url strategy mimics the browser's history as closely as possible /// while doing it all in memory with no interaction with the browser. /// /// It keeps a list of history entries and event listeners in memory and /// manipulates them in order to achieve the desired functionality. -class TestLocationStrategy extends LocationStrategy { - /// Creates a instance of [TestLocationStrategy] with an empty string as the +class TestUrlStrategy extends UrlStrategy { + /// Creates a instance of [TestUrlStrategy] with an empty string as the /// path. - factory TestLocationStrategy() => TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '')); + factory TestUrlStrategy() => TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '')); - /// Creates an instance of [TestLocationStrategy] and populates it with a list + /// Creates an instance of [TestUrlStrategy] and populates it with a list /// that has [initialEntry] as the only item. - TestLocationStrategy.fromEntry(TestHistoryEntry initialEntry) + TestUrlStrategy.fromEntry(TestHistoryEntry initialEntry) : _currentEntryIndex = 0, history = [initialEntry]; @override - String get path => currentEntry.url; + String getPath() => currentEntry.url; @override - dynamic get state { - return currentEntry.state; - } + dynamic getState() => currentEntry.state; int _currentEntryIndex; int get currentEntryIndex => _currentEntryIndex; @@ -105,12 +103,12 @@ class TestLocationStrategy extends LocationStrategy { } @override - Future back({int count = 1}) { + Future go(int count) { assert(withinAppHistory); - // Browsers don't move back in history immediately. They do it at the next + // Browsers don't move in history immediately. They do it at the next // event loop. So let's simulate that. return _nextEventLoop(() { - _currentEntryIndex = _currentEntryIndex - count; + _currentEntryIndex = _currentEntryIndex + count; if (withinAppHistory) { _firePopStateEvent(); } diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 94d73f6c7d717..64d5e0ab0126d 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -13,16 +13,19 @@ const bool _debugPrintPlatformMessages = false; /// This may be overridden in tests, for example, to pump fake frames. ui.VoidCallback? scheduleFrameCallback; +typedef _UrlStrategyListener = void Function(JsUrlStrategy); + +// KEEP THIS JS NAME IN SYNC WITH flutter_web_plugins. +// Find it at: `lib/src/js_url_strategy.dart`. +@JS('_flutter_web_set_location_strategy') +/// A JavaScript hook to customize the URL strategy of a Flutter app. +external set _onUrlStrategy(_UrlStrategyListener? listener); + /// The Web implementation of [ui.Window]. class EngineWindow extends ui.Window { EngineWindow() { _addBrightnessMediaQueryListener(); - js.context['_flutter_web_set_location_strategy'] = (LocationStrategy strategy) { - locationStrategy = strategy; - }; - registerHotRestartListener(() { - js.context['_flutter_web_set_location_strategy'] = null; - }); + _addUrlStrategyListener(); } @override @@ -173,24 +176,24 @@ class EngineWindow extends ui.Window { if (_browserHistory is MultiEntriesBrowserHistory) { return; } - final LocationStrategy? strategy = _browserHistory.locationStrategy; + final JsUrlStrategy? strategy = _browserHistory.urlStrategy; if (strategy != null) - await _browserHistory.setLocationStrategy(null); + await _browserHistory.setUrlStrategy(null); _browserHistory = MultiEntriesBrowserHistory(); if (strategy != null) - await _browserHistory.setLocationStrategy(strategy); + await _browserHistory.setUrlStrategy(strategy); } Future _useSingleEntryBrowserHistory() async { if (_browserHistory is SingleEntryBrowserHistory) { return; } - final LocationStrategy? strategy = _browserHistory.locationStrategy; + final JsUrlStrategy? strategy = _browserHistory.urlStrategy; if (strategy != null) - await _browserHistory.setLocationStrategy(null); + await _browserHistory.setUrlStrategy(null); _browserHistory = SingleEntryBrowserHistory(); if (strategy != null) - await _browserHistory.setLocationStrategy(strategy); + await _browserHistory.setUrlStrategy(strategy); } /// Simulates clicking the browser's back button. @@ -198,12 +201,12 @@ class EngineWindow extends ui.Window { /// Lazily initialized when the `defaultRouteName` getter is invoked. /// - /// The reason for the lazy initialization is to give enough time for the app to set [locationStrategy] + /// The reason for the lazy initialization is to give enough time for the app to set [urlStrategy] /// in `lib/src/ui/initialization.dart`. String? _defaultRouteName; @override - String get defaultRouteName => _defaultRouteName ??= _browserHistory.currentPath; + String get defaultRouteName => _defaultRouteName ??= urlStrategy?.getPath() ?? '/'; @override void scheduleFrame() { @@ -214,17 +217,30 @@ class EngineWindow extends ui.Window { scheduleFrameCallback!(); } + @visibleForTesting + JsUrlStrategy? urlStrategy = ui.debugEmulateFlutterTesterEnvironment + ? null + : convertToJsUrlStrategy(const HashUrlStrategy()); + /// Change the strategy to use for handling browser history location. /// Setting this member will automatically update [_browserHistory]. /// /// By setting this to null, the browser history will be disabled. - set locationStrategy(LocationStrategy? strategy) { - _browserHistory.setLocationStrategy(strategy); + Future setUrlStrategy(JsUrlStrategy? strategy) { + _isHistoryInitialized = true; + urlStrategy = strategy; + return _browserHistory.setUrlStrategy(strategy); } - /// Returns the currently active location strategy. + /// Given a [UrlStrategy] instance, converts it to [JsUrlStrategy] and sets it + /// on [_browserHistory]. + /// + /// This is only a convenience for testing. Apps will use JS-interop to set + /// their [UrlStrategy]. @visibleForTesting - LocationStrategy? get locationStrategy => _browserHistory.locationStrategy; + Future debugConvertAndSetUrlStrategy(UrlStrategy? strategy) { + return setUrlStrategy(convertToJsUrlStrategy(strategy)); + } @override ui.VoidCallback? get onTextScaleFactorChanged => _onTextScaleFactorChanged; @@ -646,27 +662,11 @@ class EngineWindow extends ui.Window { return; case 'flutter/navigation': - const MethodCodec codec = JSONMethodCodec(); - final MethodCall decoded = codec.decodeMethodCall(data); - final Map message = decoded.arguments as Map; - switch (decoded.method) { - case 'routeUpdated': - _useSingleEntryBrowserHistory().then((void data) { - _browserHistory.setRouteName(message['routeName']); - _replyToPlatformMessage( - callback, codec.encodeSuccessEnvelope(true)); - }); - break; - case 'routeInformationUpdated': - assert(_browserHistory is MultiEntriesBrowserHistory); - _browserHistory.setRouteName( - message['location'], - state: message['state'], - ); - _replyToPlatformMessage( - callback, codec.encodeSuccessEnvelope(true)); - break; - } + _handleNavigationMessage(data, callback).then((handled) { + if (!handled && callback != null) { + callback(null); + } + }); // As soon as Flutter starts taking control of the app navigation, we // should reset [_defaultRouteName] to "/" so it doesn't have any // further effect after this point. @@ -685,6 +685,46 @@ class EngineWindow extends ui.Window { _replyToPlatformMessage(callback, null); } + bool _isHistoryInitialized = false; + + @visibleForTesting + Future debugResetHistory() async { + _isHistoryInitialized = false; + urlStrategy = null; + await _browserHistory.setUrlStrategy(null); + _browserHistory = MultiEntriesBrowserHistory(); + } + + Future _handleNavigationMessage( + ByteData? data, + ui.PlatformMessageResponseCallback? callback, + ) async { + const MethodCodec codec = JSONMethodCodec(); + final MethodCall decoded = codec.decodeMethodCall(data); + final Map arguments = decoded.arguments; + + if (!_isHistoryInitialized) { + await setUrlStrategy(urlStrategy); + } + + switch (decoded.method) { + case 'routeUpdated': + await _useSingleEntryBrowserHistory(); + _browserHistory.setRouteName(arguments['routeName']); + _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + return true; + case 'routeInformationUpdated': + assert(_browserHistory is MultiEntriesBrowserHistory); + _browserHistory.setRouteName( + arguments['location'], + state: arguments['state'], + ); + _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); + return true; + } + return false; + } + int _getHapticFeedbackDuration(String? type) { switch (type) { case 'HapticFeedbackType.lightImpact': @@ -756,6 +796,16 @@ class EngineWindow extends ui.Window { }); } + void _addUrlStrategyListener() { + // TODO(mdebbar): Do we need "allowInterop" here? + _onUrlStrategy = allowInterop((JsUrlStrategy strategy) { + setUrlStrategy(strategy); + }); + registerHotRestartListener(() { + _onUrlStrategy = null; + }); + } + /// Remove the callback function for listening changes in [_brightnessMediaQuery] value. void _removeBrightnessMediaQueryListener() { _brightnessMediaQuery.removeListener(_brightnessMediaQueryListener); diff --git a/lib/web_ui/lib/src/ui/initialization.dart b/lib/web_ui/lib/src/ui/initialization.dart index a7b06b3586def..ca317304ec79b 100644 --- a/lib/web_ui/lib/src/ui/initialization.dart +++ b/lib/web_ui/lib/src/ui/initialization.dart @@ -21,10 +21,6 @@ Future webOnlyInitializePlatform({ Future _initializePlatform({ engine.AssetManager? assetManager, }) async { - if (!debugEmulateFlutterTesterEnvironment) { - engine.window.locationStrategy = const engine.HashLocationStrategy(); - } - engine.initializeEngine(); // This needs to be after `webOnlyInitializeEngine` because that is where the diff --git a/lib/web_ui/test/engine/history_test.dart b/lib/web_ui/test/engine/history_test.dart index 4c11ed0033636..f7155b11773b6 100644 --- a/lib/web_ui/test/engine/history_test.dart +++ b/lib/web_ui/test/engine/history_test.dart @@ -16,9 +16,11 @@ import 'package:ui/src/engine.dart'; import '../spy.dart'; -TestLocationStrategy get strategy => window.browserHistory.locationStrategy; -Future setStrategy(TestLocationStrategy newStrategy) async { - await window.browserHistory.setLocationStrategy(newStrategy); +TestUrlStrategy _strategy; +TestUrlStrategy get strategy => _strategy; +Future setStrategy(TestUrlStrategy newStrategy) async { + _strategy = newStrategy; + await window.debugConvertAndSetUrlStrategy(newStrategy); } Map _wrapOriginState(dynamic state) { @@ -54,11 +56,11 @@ void testMain() { tearDown(() async { spy.tearDown(); - await setStrategy(null); + await window.debugResetHistory(); }); test('basic setup works', () async { - await setStrategy(TestLocationStrategy.fromEntry( + await setStrategy(TestUrlStrategy.fromEntry( TestHistoryEntry('initial state', null, '/initial'))); // There should be two entries: origin and flutter. @@ -82,7 +84,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('browser back button pops routes correctly', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); // Initially, we should be on the flutter entry. expect(strategy.history, hasLength(2)); expect(strategy.currentEntry.state, flutterState); @@ -98,7 +100,7 @@ void testMain() { // No platform messages have been sent so far. expect(spy.messages, isEmpty); // Clicking back should take us to page1. - await strategy.back(); + await strategy.go(-1); // First, the framework should've received a `popRoute` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -115,7 +117,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('multiple browser back clicks', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); await routeUpdated('/page1'); await routeUpdated('/page2'); @@ -127,7 +129,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page2'); // Back to page1. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `popRoute` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -143,7 +145,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page1'); // Back to home. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `popRoute` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -161,8 +163,8 @@ void testMain() { // 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(); + TestUrlStrategy originalStrategy = strategy; + await originalStrategy.go(-1); // 1. The engine sends a `popRoute` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -181,7 +183,7 @@ void testMain() { browserEngine == BrowserEngine.webkit); test('handle user-provided url', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); await strategy.simulateUserTypingUrl('/page3'); // This delay is necessary to wait for [BrowserHistory] because it @@ -202,7 +204,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page3'); // Back to home. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `popRoute` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -221,7 +223,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('user types unknown url', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); await strategy.simulateUserTypingUrl('/unknown'); // This delay is necessary to wait for [BrowserHistory] because it @@ -254,11 +256,11 @@ void testMain() { tearDown(() async { spy.tearDown(); - await setStrategy(null); + await window.debugResetHistory(); }); test('basic setup works', () async { - await setStrategy(TestLocationStrategy.fromEntry( + await setStrategy(TestUrlStrategy.fromEntry( TestHistoryEntry('initial state', null, '/initial'))); // There should be only one entry. @@ -273,7 +275,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('browser back button push route infromation correctly', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + await setStrategy(TestUrlStrategy.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)); @@ -289,7 +291,7 @@ void testMain() { // No platform messages have been sent so far. expect(spy.messages, isEmpty); // Clicking back should take us to page1. - await strategy.back(); + await strategy.go(-1); // First, the framework should've received a `pushRouteInformation` // platform message. expect(spy.messages, hasLength(1)); @@ -310,7 +312,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('multiple browser back clicks', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); await routeInfomrationUpdated('/page1', 'page1 state'); await routeInfomrationUpdated('/page2', 'page2 state'); @@ -322,7 +324,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page2'); // Back to page1. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `pushRouteInformation` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -338,7 +340,7 @@ void testMain() { expect(strategy.currentEntry.state, _tagStateWithSerialCount('page1 state', 1)); expect(strategy.currentEntry.url, '/page1'); // Back to home. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `pushRouteInformation` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -359,7 +361,7 @@ void testMain() { browserEngine == BrowserEngine.webkit); test('handle user-provided url', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); await strategy.simulateUserTypingUrl('/page3'); // This delay is necessary to wait for [BrowserHistory] because it @@ -381,7 +383,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page3'); // Back to home. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `pushRouteInformation` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -401,7 +403,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('forward button works', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); await routeInfomrationUpdated('/page1', 'page1 state'); await routeInfomrationUpdated('/page2', 'page2 state'); @@ -413,7 +415,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page2'); // Back to page1. - await strategy.back(); + await strategy.go(-1); // 1. The engine sends a `pushRouteInformation` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -430,7 +432,7 @@ void testMain() { expect(strategy.currentEntry.url, '/page1'); // Forward to page2 - await strategy.back(count: -1); + await strategy.go(1); // 1. The engine sends a `pushRouteInformation` platform message. expect(spy.messages, hasLength(1)); expect(spy.messages[0].channel, 'flutter/navigation'); @@ -450,7 +452,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); }); - group('$HashLocationStrategy', () { + group('$HashUrlStrategy', () { TestPlatformLocation location; setUp(() { @@ -462,26 +464,26 @@ void testMain() { }); test('leading slash is optional', () { - final HashLocationStrategy strategy = HashLocationStrategy(location); + final HashUrlStrategy strategy = HashUrlStrategy(location); location.hash = '#/'; - expect(strategy.path, '/'); + expect(strategy.getPath(), '/'); location.hash = '#/foo'; - expect(strategy.path, '/foo'); + expect(strategy.getPath(), '/foo'); location.hash = '#foo'; - expect(strategy.path, 'foo'); + expect(strategy.getPath(), 'foo'); }); test('path should not be empty', () { - final HashLocationStrategy strategy = HashLocationStrategy(location); + final HashUrlStrategy strategy = HashUrlStrategy(location); location.hash = ''; - expect(strategy.path, '/'); + expect(strategy.getPath(), '/'); location.hash = '#'; - expect(strategy.path, '/'); + expect(strategy.getPath(), '/'); }); }); } @@ -529,31 +531,28 @@ class TestPlatformLocation extends PlatformLocation { String hash; dynamic state; + @override void onPopState(html.EventListener fn) { throw UnimplementedError(); } + @override void offPopState(html.EventListener fn) { throw UnimplementedError(); } - void onHashChange(html.EventListener fn) { - throw UnimplementedError(); - } - - void offHashChange(html.EventListener fn) { - throw UnimplementedError(); - } - + @override void pushState(dynamic state, String title, String url) { throw UnimplementedError(); } + @override void replaceState(dynamic state, String title, String url) { throw UnimplementedError(); } - void back(int count) { + @override + void go(int count) { throw UnimplementedError(); } } diff --git a/lib/web_ui/test/engine/navigation_test.dart b/lib/web_ui/test/engine/navigation_test.dart index 44d3bf2939e95..e2ce89519441d 100644 --- a/lib/web_ui/test/engine/navigation_test.dart +++ b/lib/web_ui/test/engine/navigation_test.dart @@ -10,7 +10,7 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart' as engine; -engine.TestLocationStrategy _strategy; +engine.TestUrlStrategy _strategy; const engine.MethodCodec codec = engine.JSONMethodCodec(); @@ -21,12 +21,14 @@ void main() { } void testMain() { - setUp(() { - engine.window.locationStrategy = _strategy = engine.TestLocationStrategy(); + setUp(() async { + _strategy = engine.TestUrlStrategy(); + await engine.window.debugConvertAndSetUrlStrategy(_strategy); }); - tearDown(() { - engine.window.locationStrategy = _strategy = null; + tearDown(() async { + _strategy = null; + await engine.window.debugResetHistory(); }); test('Tracks pushed, replaced and popped routes', () async { @@ -40,6 +42,6 @@ void testMain() { (_) => completer.complete(), ); await completer.future; - expect(_strategy.path, '/foo'); + expect(_strategy.getPath(), '/foo'); }); } diff --git a/lib/web_ui/test/window_test.dart b/lib/web_ui/test/window_test.dart index b83849bffc8d6..a03888bc440f9 100644 --- a/lib/web_ui/test/window_test.dart +++ b/lib/web_ui/test/window_test.dart @@ -14,11 +14,7 @@ import 'package:ui/src/engine.dart'; const MethodCodec codec = JSONMethodCodec(); -void emptyCallback(ByteData date) {} - -Future setStrategy(TestLocationStrategy newStrategy) async { - await window.browserHistory.setLocationStrategy(newStrategy); -} +void emptyCallback(ByteData data) {} void main() { internalBootstrapBrowserTest(() => testMain); @@ -29,17 +25,27 @@ void testMain() { await window.debugSwitchBrowserHistory(useSingle: true); }); + tearDown(() async { + await window.debugResetHistory(); + }); + test('window.defaultRouteName should not change', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/initial'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/initial'), + ); + await window.debugConvertAndSetUrlStrategy(strategy); expect(window.defaultRouteName, '/initial'); // Changing the URL in the address bar later shouldn't affect [window.defaultRouteName]. - window.locationStrategy.replaceState(null, null, '/newpath'); + strategy.replaceState(null, null, '/newpath'); expect(window.defaultRouteName, '/initial'); }); - test('window.defaultRouteName should reset after navigation platform message', () async { - await setStrategy(TestLocationStrategy.fromEntry(TestHistoryEntry('initial state', null, '/initial'))); + test('window.defaultRouteName should reset after navigation platform message', + () async { + await window.debugConvertAndSetUrlStrategy(TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/initial'), + )); // Reading it multiple times should return the same value. expect(window.defaultRouteName, '/initial'); expect(window.defaultRouteName, '/initial'); @@ -57,26 +63,26 @@ void testMain() { }); test('can disable location strategy', () async { - await window.debugSwitchBrowserHistory(useSingle: true); - final testStrategy = TestLocationStrategy.fromEntry( + final testStrategy = TestUrlStrategy.fromEntry( TestHistoryEntry('initial state', null, '/'), ); - await setStrategy(testStrategy); + await window.debugConvertAndSetUrlStrategy(testStrategy); - expect(window.locationStrategy, testStrategy); + expect(window.urlStrategy, isNotNull); // 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, 'state': 'initial state'}); + 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. + expect(() => jsSetUrlStrategy(null), returnsNormally); + // The url strategy is teared down asynchronously. await Future.delayed(Duration.zero); - expect(window.locationStrategy, isNull); + expect(window.urlStrategy, isNull); // The listener is removed asynchronously. await Future.delayed(const Duration(milliseconds: 10)); @@ -89,13 +95,13 @@ void testMain() { }); test('js interop throws on wrong type', () { - expect(() => jsSetLocationStrategy(123), throwsA(anything)); - expect(() => jsSetLocationStrategy('foo'), throwsA(anything)); - expect(() => jsSetLocationStrategy(false), throwsA(anything)); + expect(() => jsSetUrlStrategy(123), throwsA(anything)); + expect(() => jsSetUrlStrategy('foo'), throwsA(anything)); + expect(() => jsSetUrlStrategy(false), throwsA(anything)); }); } -void jsSetLocationStrategy(dynamic strategy) { +void jsSetUrlStrategy(dynamic strategy) { js_util.callMethod( html.window, '_flutter_web_set_location_strategy', From c5ae918b5f44a01fed5912125f1bcba1a9ffa8cd Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Fri, 25 Sep 2020 11:52:27 -0700 Subject: [PATCH 2/7] licenses --- ci/licenses_golden/licenses_flutter | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index ed74366279e94..72bfafac4c963 100755 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -426,7 +426,6 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/alarm_clock.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/assets.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/bitmap_canvas.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/browser_detection.dart -FILE: ../../../flutter/lib/web_ui/lib/src/engine/browser_location.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvas_pool.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/canvas.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -463,7 +462,9 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/dom_canvas.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/dom_renderer.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/engine_canvas.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/frame_reference.dart -FILE: ../../../flutter/lib/web_ui/lib/src/engine/history.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/history/history.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/history/js_url_strategy.dart +FILE: ../../../flutter/lib/web_ui/lib/src/engine/history/url_strategy.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/html/backdrop_filter.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/html/canvas.dart FILE: ../../../flutter/lib/web_ui/lib/src/engine/html/clip.dart From 6b0959f21ba3c6bb8a3f7c16b505b122a0fb03fb Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Fri, 25 Sep 2020 13:29:53 -0700 Subject: [PATCH 3/7] dartdocs --- lib/web_ui/lib/src/engine/history/js_url_strategy.dart | 4 ---- lib/web_ui/lib/src/engine/history/url_strategy.dart | 10 ++++++++-- lib/web_ui/lib/src/engine/window.dart | 5 +---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart b/lib/web_ui/lib/src/engine/history/js_url_strategy.dart index 96afffcfd731a..16e974b62fefd 100644 --- a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart +++ b/lib/web_ui/lib/src/engine/history/js_url_strategy.dart @@ -33,7 +33,6 @@ JsUrlStrategy? convertToJsUrlStrategy(UrlStrategy? strategy) { pushState: allowInterop(strategy.pushState), replaceState: allowInterop(strategy.replaceState), go: allowInterop(strategy.go), - // getBaseHref: allowInterop(strategy.getBaseHref), ); } @@ -78,7 +77,4 @@ abstract class JsUrlStrategy { /// Moves forwards or backwards through the history stack. external Future go(int count); - - // TODO: add this: - // external String getBaseHref(); } diff --git a/lib/web_ui/lib/src/engine/history/url_strategy.dart b/lib/web_ui/lib/src/engine/history/url_strategy.dart index b88b961094af2..ebaebf64ea856 100644 --- a/lib/web_ui/lib/src/engine/history/url_strategy.dart +++ b/lib/web_ui/lib/src/engine/history/url_strategy.dart @@ -134,7 +134,7 @@ class HashUrlStrategy extends UrlStrategy { /// [UrlStrategy] when they need to interact with the DOM apis like /// pushState, popState, etc... abstract class PlatformLocation { - /// + /// This constructor is here only to allow subclasses to be const. const PlatformLocation(); /// Registers an event listener for the `onpopstate` event. @@ -183,11 +183,14 @@ abstract class PlatformLocation { /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/go void go(int count); + + /// The base href where the flutter app is being served. + String? getBaseHref(); } /// An implementation of [PlatformLocation] for the browser. class BrowserPlatformLocation extends PlatformLocation { - /// + /// Default constructor for [BrowserPlatformLocation]. const BrowserPlatformLocation(); html.Location get _location => html.window.location; @@ -229,4 +232,7 @@ class BrowserPlatformLocation extends PlatformLocation { void go(int count) { _history.go(count); } + + @override + String? getBaseHref() => html.document.baseUri; } diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 64d5e0ab0126d..2fba08b5ca4b1 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -797,10 +797,7 @@ class EngineWindow extends ui.Window { } void _addUrlStrategyListener() { - // TODO(mdebbar): Do we need "allowInterop" here? - _onUrlStrategy = allowInterop((JsUrlStrategy strategy) { - setUrlStrategy(strategy); - }); + _onUrlStrategy = setUrlStrategy; registerHotRestartListener(() { _onUrlStrategy = null; }); From 2742d20bdf7d486ebebaec006435390935db012b Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Fri, 25 Sep 2020 13:55:03 -0700 Subject: [PATCH 4/7] fix test --- lib/web_ui/lib/src/engine/window.dart | 2 +- lib/web_ui/test/engine/history_test.dart | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 2fba08b5ca4b1..27154080eb925 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -797,7 +797,7 @@ class EngineWindow extends ui.Window { } void _addUrlStrategyListener() { - _onUrlStrategy = setUrlStrategy; + _onUrlStrategy = allowInterop(setUrlStrategy); registerHotRestartListener(() { _onUrlStrategy = null; }); diff --git a/lib/web_ui/test/engine/history_test.dart b/lib/web_ui/test/engine/history_test.dart index f7155b11773b6..9f8593e0c2b9b 100644 --- a/lib/web_ui/test/engine/history_test.dart +++ b/lib/web_ui/test/engine/history_test.dart @@ -555,4 +555,7 @@ class TestPlatformLocation extends PlatformLocation { void go(int count) { throw UnimplementedError(); } + + @override + String getBaseHref() => '/'; } From 0f2f26f714f89ac74d9da368527a5d608dca245f Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Thu, 1 Oct 2020 11:09:05 -0700 Subject: [PATCH 5/7] refactor --- .../lib/src/engine/history/history.dart | 197 +++++++++--------- .../src/engine/history/js_url_strategy.dart | 52 +++-- .../lib/src/engine/history/url_strategy.dart | 121 ++++++++--- lib/web_ui/lib/src/engine/test_embedding.dart | 4 +- lib/web_ui/lib/src/engine/window.dart | 166 +++++++-------- lib/web_ui/test/engine/history_test.dart | 67 ++++-- lib/web_ui/test/engine/navigation_test.dart | 2 +- lib/web_ui/test/window_test.dart | 70 +++---- 8 files changed, 378 insertions(+), 301 deletions(-) diff --git a/lib/web_ui/lib/src/engine/history/history.dart b/lib/web_ui/lib/src/engine/history/history.dart index beddf4f4fb7e6..72c2e60d6a7ac 100644 --- a/lib/web_ui/lib/src/engine/history/history.dart +++ b/lib/web_ui/lib/src/engine/history/history.dart @@ -22,55 +22,37 @@ part of engine; /// * [MultiEntriesBrowserHistory]: which creates a set of states that records /// the navigating events happened in the framework. abstract class BrowserHistory { + static BrowserHistory defaultImpl({required UrlStrategy? urlStrategy}) { + return MultiEntriesBrowserHistory(urlStrategy: urlStrategy); + } + late ui.VoidCallback _unsubscribe; /// The strategy to interact with html browser history. - JsUrlStrategy? get urlStrategy => _urlStrategy; - JsUrlStrategy? _urlStrategy; - /// Updates the strategy. - /// - /// This method will also remove any previous modifications to the html - /// browser history and start anew. - Future setUrlStrategy(JsUrlStrategy? strategy) async { - if (strategy != _urlStrategy) { - await _tearoffStrategy(_urlStrategy); - _urlStrategy = strategy; - await _setupStrategy(_urlStrategy); - } - } - - Future _setupStrategy(JsUrlStrategy? strategy) async { - if (strategy == null) { - return; - } - _unsubscribe = strategy.onPopState(onPopState as dynamic Function(html.Event)); - await setup(); - } + UrlStrategy? get urlStrategy; - Future _tearoffStrategy(JsUrlStrategy? strategy) async { - if (strategy == null) { - return; - } - _unsubscribe(); + bool _isDisposed = false; - await tearDown(); + void _setupStrategy(UrlStrategy strategy) { + _unsubscribe = strategy.addPopStateListener( + onPopState as html.EventListener, + ); } /// Exit this application and return to the previous page. Future exit() async { - if (_urlStrategy != null) { - await _tearoffStrategy(_urlStrategy); + if (urlStrategy != null) { + await tearDown(); // Now the history should be in the original state, back one more time to // exit the application. - await _urlStrategy!.go(-1); - _urlStrategy = null; + await urlStrategy!.go(-1); } } /// This method does the same thing as the browser back button. Future back() { - if (_urlStrategy != null) { - return _urlStrategy!.go(-1); + if (urlStrategy != null) { + return urlStrategy!.go(-1); } return Future.value(); } @@ -79,10 +61,10 @@ abstract class BrowserHistory { String get currentPath => urlStrategy?.getPath() ?? '/'; /// The state of the current location of the user's browser. - dynamic get currentState => urlStrategy?.getState(); + Object? get currentState => urlStrategy?.getState(); /// Update the url with the given [routeName] and [state]. - void setRouteName(String? routeName, {dynamic? state}); + void setRouteName(String? routeName, {Object? state}); /// A callback method to handle browser backward or forward buttons. /// @@ -90,12 +72,9 @@ abstract class BrowserHistory { /// applications accordingly. void onPopState(covariant html.PopStateEvent event); - /// Sets up any prerequisites to use this browser history class. - Future setup() => Future.value(); - /// Restore any modifications to the html browser history during the lifetime /// of this class. - Future tearDown() => Future.value(); + Future tearDown(); } /// A browser history class that creates a set of browser history entries to @@ -113,27 +92,47 @@ abstract class BrowserHistory { /// * [SingleEntryBrowserHistory], which is used when the framework does not use /// a Router for routing. class MultiEntriesBrowserHistory extends BrowserHistory { + MultiEntriesBrowserHistory({required this.urlStrategy}) { + final UrlStrategy? strategy = urlStrategy; + if (strategy == null) { + return; + } + + _setupStrategy(strategy); + if (!_hasSerialCount(currentState)) { + strategy.replaceState( + _tagWithSerialCount(currentState, 0), 'flutter', currentPath); + } + // If we restore from a page refresh, the _currentSerialCount may not be 0. + _lastSeenSerialCount = _currentSerialCount; + } + + @override + final UrlStrategy? urlStrategy; + late int _lastSeenSerialCount; int get _currentSerialCount { if (_hasSerialCount(currentState)) { - return currentState['serialCount'] as int; + final Map stateMap = + currentState as Map; + return stateMap['serialCount'] as int; } return 0; } - dynamic _tagWithSerialCount(dynamic originialState, int count) { - return { + Object? _tagWithSerialCount(Object? originialState, int count) { + return { 'serialCount': count, 'state': originialState, }; } - bool _hasSerialCount(dynamic state) { + bool _hasSerialCount(Object? state) { return state is Map && state['serialCount'] != null; } @override - void setRouteName(String? routeName, {dynamic? state}) { + void setRouteName(String? routeName, {Object? state}) { if (urlStrategy != null) { assert(routeName != null); _lastSeenSerialCount += 1; @@ -154,41 +153,32 @@ class MultiEntriesBrowserHistory extends BrowserHistory { // In this case we assume this will be the next history entry from the // last seen entry. urlStrategy!.replaceState( - _tagWithSerialCount(event.state, _lastSeenSerialCount + 1), - 'flutter', - currentPath); + _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'], - }) - ), + MethodCall('pushRouteInformation', { + 'location': currentPath, + 'state': event.state?['state'], + })), (_) {}, ); } } @override - Future setup() { - if (!_hasSerialCount(currentState)) { - urlStrategy!.replaceState( - _tagWithSerialCount(currentState, 0), - 'flutter', - currentPath - ); + Future tearDown() async { + if (_isDisposed || urlStrategy == null) { + return; } - // If we retore from a page refresh, the _currentSerialCount may not be 0. - _lastSeenSerialCount = _currentSerialCount; - return Future.value(); - } + _isDisposed = true; + _unsubscribe(); - @override - Future tearDown() async { // Restores the html browser history. assert(_hasSerialCount(currentState)); int backCount = _currentSerialCount; @@ -197,8 +187,10 @@ class MultiEntriesBrowserHistory extends BrowserHistory { } // Unwrap state. assert(_hasSerialCount(currentState) && _currentSerialCount == 0); + final Map stateMap = + currentState as Map; urlStrategy!.replaceState( - currentState['state'], + stateMap['state'], 'flutter', currentPath, ); @@ -222,35 +214,60 @@ class MultiEntriesBrowserHistory extends BrowserHistory { /// * [MultiEntriesBrowserHistory], which is used when the framework uses a /// Router for routing. class SingleEntryBrowserHistory extends BrowserHistory { + SingleEntryBrowserHistory({required this.urlStrategy}) { + final UrlStrategy? strategy = urlStrategy; + if (strategy == null) { + return; + } + + _setupStrategy(strategy); + + final String path = currentPath; + if (_isFlutterEntry(html.window.history.state)) { + // This could happen if the user, for example, refreshes the page. They + // will land directly on the "flutter" entry, so there's no need to setup + // the "origin" and "flutter" entries, we can safely assume they are + // already setup. + } else { + _setupOriginEntry(strategy); + _setupFlutterEntry(strategy, replace: false, path: path); + } + } + + @override + final UrlStrategy? urlStrategy; + static const MethodCall _popRouteMethodCall = MethodCall('popRoute'); static const String _kFlutterTag = 'flutter'; static const String _kOriginTag = 'origin'; - Map _wrapOriginState(dynamic state) { + Map _wrapOriginState(Object? state) { return {_kOriginTag: true, 'state': state}; } - dynamic _unwrapOriginState(dynamic state) { + + Object? _unwrapOriginState(Object? 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) { + bool _isOriginEntry(Object? 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) { + bool _isFlutterEntry(Object? state) { return state is Map && state[_kFlutterTag] == true; } @override - void setRouteName(String? routeName, {dynamic? state}) { + void setRouteName(String? routeName, {Object? state}) { if (urlStrategy != null) { _setupFlutterEntry(urlStrategy!, replace: true, path: routeName); } @@ -260,7 +277,7 @@ class SingleEntryBrowserHistory extends BrowserHistory { @override void onPopState(covariant html.PopStateEvent event) { if (_isOriginEntry(event.state)) { - _setupFlutterEntry(_urlStrategy!); + _setupFlutterEntry(urlStrategy!); // 2. Send a 'popRoute' platform message so the app can handle it accordingly. if (window._onPlatformMessage != null) { @@ -302,14 +319,14 @@ class SingleEntryBrowserHistory extends BrowserHistory { // 2. Then we remove the new entry. // This will take us back to our "flutter" entry and it causes a new // popstate event that will be handled in the "else if" section above. - _urlStrategy!.go(-1); + urlStrategy!.go(-1); } } /// This method should be called when the Origin Entry is active. It just /// replaces the state of the entry so that we can recognize it later using /// [_isOriginEntry] inside [_popStateListener]. - void _setupOriginEntry(JsUrlStrategy strategy) { + void _setupOriginEntry(UrlStrategy strategy) { assert(strategy != null); // ignore: unnecessary_null_comparison strategy.replaceState(_wrapOriginState(currentState), 'origin', ''); } @@ -317,7 +334,7 @@ class SingleEntryBrowserHistory extends BrowserHistory { /// This method is used manipulate the Flutter Entry which is always the /// active entry while the Flutter app is running. void _setupFlutterEntry( - JsUrlStrategy strategy, { + UrlStrategy strategy, { bool replace = false, String? path, }) { @@ -330,28 +347,18 @@ class SingleEntryBrowserHistory extends BrowserHistory { } } - @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 - // will land directly on the "flutter" entry, so there's no need to setup - // the "origin" and "flutter" entries, we can safely assume they are - // already setup. - } else { - _setupOriginEntry(urlStrategy!); - _setupFlutterEntry(urlStrategy!, replace: false, path: path); - } - return Future.value(); - } - @override Future tearDown() async { - if (urlStrategy != null) { - // We need to remove the flutter entry that we pushed in setup. - await urlStrategy!.go(-1); - // Restores original state. - urlStrategy!.replaceState(_unwrapOriginState(currentState), 'flutter', currentPath); + if (_isDisposed || urlStrategy == null) { + return; } + _isDisposed = true; + _unsubscribe(); + + // We need to remove the flutter entry that we pushed in setup. + await urlStrategy!.go(-1); + // Restores original state. + urlStrategy! + .replaceState(_unwrapOriginState(currentState), 'flutter', currentPath); } } diff --git a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart b/lib/web_ui/lib/src/engine/history/js_url_strategy.dart index 16e974b62fefd..7c13fab8cce05 100644 --- a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart +++ b/lib/web_ui/lib/src/engine/history/js_url_strategy.dart @@ -7,14 +7,14 @@ part of engine; typedef _PathGetter = String Function(); -typedef _StateGetter = dynamic Function(); +typedef _StateGetter = Object? Function(); -typedef _OnPopState = ui.VoidCallback Function(html.EventListener); +typedef _AddPopStateListener = ui.VoidCallback Function(html.EventListener); typedef _StringToString = String Function(String); typedef _StateOperation = void Function( - dynamic state, String title, String url); + Object? state, String title, String url); typedef _HistoryMove = Future Function(int count); @@ -28,7 +28,7 @@ JsUrlStrategy? convertToJsUrlStrategy(UrlStrategy? strategy) { return JsUrlStrategy( getPath: allowInterop(strategy.getPath), getState: allowInterop(strategy.getState), - onPopState: allowInterop(strategy.onPopState), + addPopStateListener: allowInterop(strategy.addPopStateListener), prepareExternalUrl: allowInterop(strategy.prepareExternalUrl), pushState: allowInterop(strategy.pushState), replaceState: allowInterop(strategy.replaceState), @@ -39,42 +39,58 @@ JsUrlStrategy? convertToJsUrlStrategy(UrlStrategy? strategy) { /// The JavaScript representation of a URL strategy. /// /// This is used to pass URL strategy implementations across a JS-interop -/// bridge. +/// bridge from the app to the engine. @JS() @anonymous abstract class JsUrlStrategy { /// Creates an instance of [JsUrlStrategy] from a bag of URL strategy /// functions. external factory JsUrlStrategy({ - @required _PathGetter getPath, - @required _StateGetter getState, - @required _OnPopState onPopState, - @required _StringToString prepareExternalUrl, - @required _StateOperation pushState, - @required _StateOperation replaceState, - @required _HistoryMove go, + required _PathGetter getPath, + required _StateGetter getState, + required _AddPopStateListener addPopStateListener, + required _StringToString prepareExternalUrl, + required _StateOperation pushState, + required _StateOperation replaceState, + required _HistoryMove go, }); - /// Subscribes to popstate events and returns a function that could be used to - /// unsubscribe from popstate events. - external ui.VoidCallback onPopState(html.EventListener fn); + /// Adds a listener to the `popstate` event and returns a function that, when + /// invoked, removes the listener. + external ui.VoidCallback addPopStateListener(html.EventListener fn); /// Returns the active path in the browser. external String getPath(); /// Returns the history state in the browser. - external dynamic getState(); + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/state + external Object? getState(); /// Given a path that's internal to the app, create the external url that /// will be used in the browser. external String prepareExternalUrl(String internalUrl); /// Push a new history entry. - external void pushState(dynamic state, String title, String url); + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState + external void pushState(Object? state, String title, String url); /// Replace the currently active history entry. - external void replaceState(dynamic state, String title, String url); + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState + external void replaceState(Object? state, String title, String url); /// Moves forwards or backwards through the history stack. + /// + /// A negative [count] value causes a backward move in the history stack. And + /// a positive [count] value causs a forward move. + /// + /// Examples: + /// + /// * `go(-2)` moves back 2 steps in history. + /// * `go(3)` moves forward 3 steps in hisotry. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/go external Future go(int count); } diff --git a/lib/web_ui/lib/src/engine/history/url_strategy.dart b/lib/web_ui/lib/src/engine/history/url_strategy.dart index ebaebf64ea856..9822dec10b947 100644 --- a/lib/web_ui/lib/src/engine/history/url_strategy.dart +++ b/lib/web_ui/lib/src/engine/history/url_strategy.dart @@ -5,10 +5,10 @@ // @dart = 2.10 part of engine; -/// [UrlStrategy] is responsible for representing and reading route state -/// from the browser's URL. +/// Represents and reads route state from the browser's URL. /// -/// At the moment, only one strategy is implemented: [HashUrlStrategy]. +/// By default, the [HashUrlStrategy] subclass is used if the app doesn't +/// specify one. /// /// This is used by [BrowserHistory] to interact with browser history APIs. abstract class UrlStrategy { @@ -17,25 +17,41 @@ abstract class UrlStrategy { /// Subscribes to popstate events and returns a function that could be used to /// unsubscribe from popstate events. - ui.VoidCallback onPopState(html.EventListener fn); + ui.VoidCallback addPopStateListener(html.EventListener fn); /// Returns the active path in the browser. String getPath(); /// The state of the current browser history entry. - dynamic getState(); + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/state + Object? getState(); /// Given a path that's internal to the app, create the external url that /// will be used in the browser. String prepareExternalUrl(String internalUrl); /// Push a new history entry. - void pushState(dynamic state, String title, String url); + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState + void pushState(Object? state, String title, String url); /// Replace the currently active history entry. - void replaceState(dynamic state, String title, String url); + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState + void replaceState(Object? state, String title, String url); /// Moves forwards or backwards through the history stack. + /// + /// A negative [count] value causes a backward move in the history stack. And + /// a positive [count] value causs a forward move. + /// + /// Examples: + /// + /// * `go(-2)` moves back 2 steps in history. + /// * `go(3)` moves forward 3 steps in hisotry. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/go Future go(int count); } @@ -62,9 +78,9 @@ class HashUrlStrategy extends UrlStrategy { final PlatformLocation _platformLocation; @override - ui.VoidCallback onPopState(html.EventListener fn) { - _platformLocation.onPopState(fn); - return () => _platformLocation.offPopState(fn); + ui.VoidCallback addPopStateListener(html.EventListener fn) { + _platformLocation.addPopStateListener(fn); + return () => _platformLocation.removePopStateListener(fn); } @override @@ -83,7 +99,7 @@ class HashUrlStrategy extends UrlStrategy { } @override - dynamic getState() => _platformLocation.state; + Object? getState() => _platformLocation.state; @override String prepareExternalUrl(String internalUrl) { @@ -97,12 +113,12 @@ class HashUrlStrategy extends UrlStrategy { } @override - void pushState(dynamic state, String title, String url) { + void pushState(Object? state, String title, String url) { _platformLocation.pushState(state, title, prepareExternalUrl(url)); } @override - void replaceState(dynamic state, String title, String url) { + void replaceState(Object? state, String title, String url) { _platformLocation.replaceState(state, title, prepareExternalUrl(url)); } @@ -119,7 +135,7 @@ class HashUrlStrategy extends UrlStrategy { Future _waitForPopState() { final Completer completer = Completer(); late ui.VoidCallback unsubscribe; - unsubscribe = onPopState((_) { + unsubscribe = addPopStateListener((_) { unsubscribe(); completer.complete(); }); @@ -127,28 +143,62 @@ class HashUrlStrategy extends UrlStrategy { } } +/// Wraps a custom implementation of [UrlStrategy] that was previously converted +/// to a [JsUrlStrategy]. +class CustomUrlStrategy extends UrlStrategy { + CustomUrlStrategy.fromJs(this.delegate); + + final JsUrlStrategy delegate; + + @override + ui.VoidCallback addPopStateListener(html.EventListener fn) => + delegate.addPopStateListener(fn); + + @override + String getPath() => delegate.getPath(); + + @override + Object? getState() => delegate.getState(); + + @override + String prepareExternalUrl(String internalUrl) => + delegate.prepareExternalUrl(internalUrl); + + @override + void pushState(Object? state, String title, String url) => + delegate.pushState(state, title, url); + + @override + void replaceState(Object? state, String title, String url) => + delegate.replaceState(state, title, url); + + @override + Future go(int count) => delegate.go(count); +} + /// [PlatformLocation] encapsulates all calls to DOM apis, which allows the /// [UrlStrategy] classes to be platform agnostic and testable. /// /// The [PlatformLocation] class is used directly by all implementations of /// [UrlStrategy] when they need to interact with the DOM apis like -/// pushState, popState, etc... +/// pushState, popState, etc. abstract class PlatformLocation { /// This constructor is here only to allow subclasses to be const. const PlatformLocation(); - /// Registers an event listener for the `onpopstate` event. + /// Registers an event listener for the `popstate` event. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate - void onPopState(html.EventListener fn); + void addPopStateListener(html.EventListener fn); - /// Unregisters the given listener from the`popstate` event. + /// Unregisters the given listener from the `popstate` event. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate - void offPopState(html.EventListener fn); + void removePopStateListener(html.EventListener fn); - /// The [pathname](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname) - /// part of the URL in the browser address bar. + /// The `pathname` part of the URL in the browser address bar. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname String get pathname; /// The `query` part of the URL in the browser address bar. @@ -164,27 +214,34 @@ abstract class PlatformLocation { /// The `state` in the current history entry. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/state - dynamic get state; + Object? get state; /// Adds a new entry to the browser history stack. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState - void pushState(dynamic state, String title, String url); + void pushState(Object? state, String title, String url); /// Replaces the current entry in the browser history stack. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState - void replaceState(dynamic state, String title, String url); + void replaceState(Object? state, String title, String url); /// Moves forwards or backwards through the history stack. /// - /// A negative [count] moves backwards, while a positive [count] moves - /// forwards. + /// A negative [count] value causes a backward move in the history stack. And + /// a positive [count] value causs a forward move. + /// + /// Examples: + /// + /// * `go(-2)` moves back 2 steps in history. + /// * `go(3)` moves forward 3 steps in hisotry. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/History/go void go(int count); - /// The base href where the flutter app is being served. + /// The base href where the Flutter app is being served. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base String? getBaseHref(); } @@ -197,12 +254,12 @@ class BrowserPlatformLocation extends PlatformLocation { html.History get _history => html.window.history; @override - void onPopState(html.EventListener fn) { + void addPopStateListener(html.EventListener fn) { html.window.addEventListener('popstate', fn); } @override - void offPopState(html.EventListener fn) { + void removePopStateListener(html.EventListener fn) { html.window.removeEventListener('popstate', fn); } @@ -216,15 +273,15 @@ class BrowserPlatformLocation extends PlatformLocation { String get hash => _location.hash; @override - dynamic get state => _history.state; + Object? get state => _history.state; @override - void pushState(dynamic state, String title, String url) { + void pushState(Object? state, String title, String url) { _history.pushState(state, title, url); } @override - void replaceState(dynamic state, String title, String url) { + void replaceState(Object? state, String title, String url) { _history.replaceState(state, title, url); } diff --git a/lib/web_ui/lib/src/engine/test_embedding.dart b/lib/web_ui/lib/src/engine/test_embedding.dart index cb231596fba5f..0255e5fb19601 100644 --- a/lib/web_ui/lib/src/engine/test_embedding.dart +++ b/lib/web_ui/lib/src/engine/test_embedding.dart @@ -20,7 +20,7 @@ class TestHistoryEntry { } } -/// This url strategy mimics the browser's history as closely as possible +/// This URL strategy mimics the browser's history as closely as possible /// while doing it all in memory with no interaction with the browser. /// /// It keeps a list of history entries and event listeners in memory and @@ -122,7 +122,7 @@ class TestUrlStrategy extends UrlStrategy { final List listeners = []; @override - ui.VoidCallback onPopState(html.EventListener fn) { + ui.VoidCallback addPopStateListener(html.EventListener fn) { listeners.add(fn); return () { // Schedule a micro task here to avoid removing the listener during diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 27154080eb925..5ded294d50e97 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -13,13 +13,15 @@ const bool _debugPrintPlatformMessages = false; /// This may be overridden in tests, for example, to pump fake frames. ui.VoidCallback? scheduleFrameCallback; -typedef _UrlStrategyListener = void Function(JsUrlStrategy); +typedef _JsSetUrlStrategy = void Function(JsUrlStrategy?); +/// A JavaScript hook to customize the URL strategy of a Flutter app. +// // KEEP THIS JS NAME IN SYNC WITH flutter_web_plugins. -// Find it at: `lib/src/js_url_strategy.dart`. +// Find it at: https://github.com/flutter/flutter/blob/custom_location_strategy/packages/flutter_web_plugins/lib/src/history/js_url_strategy.dart +// TODO: Add integration test https://github.com/flutter/flutter/issues/66852 @JS('_flutter_web_set_location_strategy') -/// A JavaScript hook to customize the URL strategy of a Flutter app. -external set _onUrlStrategy(_UrlStrategyListener? listener); +external set _jsSetUrlStrategy(_JsSetUrlStrategy? newJsSetUrlStrategy); /// The Web implementation of [ui.Window]. class EngineWindow extends ui.Window { @@ -29,7 +31,8 @@ class EngineWindow extends ui.Window { } @override - double get devicePixelRatio => _debugDevicePixelRatio ?? browserDevicePixelRatio; + double get devicePixelRatio => + _debugDevicePixelRatio ?? browserDevicePixelRatio; /// Returns device pixel ratio returned by browser. static double get browserDevicePixelRatio { @@ -120,7 +123,8 @@ class EngineWindow extends ui.Window { double height = 0; double width = 0; if (html.window.visualViewport != null) { - height = html.window.visualViewport!.height!.toDouble() * devicePixelRatio; + height = + html.window.visualViewport!.height!.toDouble() * devicePixelRatio; width = html.window.visualViewport!.width!.toDouble() * devicePixelRatio; } else { height = html.window.innerHeight! * devicePixelRatio; @@ -129,7 +133,7 @@ class EngineWindow extends ui.Window { // This method compares the new dimensions with the previous ones. // Return false if the previous dimensions are not set. - if(_physicalSize != null) { + if (_physicalSize != null) { // First confirm both height and width are effected. if (_physicalSize!.height != height && _physicalSize!.width != width) { // If prior to rotation height is bigger than width it should be the @@ -157,48 +161,22 @@ class EngineWindow extends ui.Window { /// Handles the browser history integration to allow users to use the back /// button, etc. @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 JsUrlStrategy? strategy = _browserHistory.urlStrategy; - if (strategy != null) - await _browserHistory.setUrlStrategy(null); - _browserHistory = MultiEntriesBrowserHistory(); - if (strategy != null) - await _browserHistory.setUrlStrategy(strategy); + BrowserHistory get browserHistory { + return _browserHistory ??= + BrowserHistory.defaultImpl(urlStrategy: const HashUrlStrategy()); } + BrowserHistory? _browserHistory; + Future _useSingleEntryBrowserHistory() async { if (_browserHistory is SingleEntryBrowserHistory) { return; } - final JsUrlStrategy? strategy = _browserHistory.urlStrategy; - if (strategy != null) - await _browserHistory.setUrlStrategy(null); - _browserHistory = SingleEntryBrowserHistory(); - if (strategy != null) - await _browserHistory.setUrlStrategy(strategy); + final UrlStrategy? strategy = _browserHistory?.urlStrategy; + await _browserHistory?.tearDown(); + _browserHistory = SingleEntryBrowserHistory(urlStrategy: strategy); } - /// Simulates clicking the browser's back button. - Future webOnlyBack() => _browserHistory.back(); - /// Lazily initialized when the `defaultRouteName` getter is invoked. /// /// The reason for the lazy initialization is to give enough time for the app to set [urlStrategy] @@ -206,42 +184,18 @@ class EngineWindow extends ui.Window { String? _defaultRouteName; @override - String get defaultRouteName => _defaultRouteName ??= urlStrategy?.getPath() ?? '/'; + String get defaultRouteName { + return _defaultRouteName ??= browserHistory.currentPath; + } @override void scheduleFrame() { if (scheduleFrameCallback == null) { - throw new Exception( - 'scheduleFrameCallback must be initialized first.'); + throw new Exception('scheduleFrameCallback must be initialized first.'); } scheduleFrameCallback!(); } - @visibleForTesting - JsUrlStrategy? urlStrategy = ui.debugEmulateFlutterTesterEnvironment - ? null - : convertToJsUrlStrategy(const HashUrlStrategy()); - - /// Change the strategy to use for handling browser history location. - /// Setting this member will automatically update [_browserHistory]. - /// - /// By setting this to null, the browser history will be disabled. - Future setUrlStrategy(JsUrlStrategy? strategy) { - _isHistoryInitialized = true; - urlStrategy = strategy; - return _browserHistory.setUrlStrategy(strategy); - } - - /// Given a [UrlStrategy] instance, converts it to [JsUrlStrategy] and sets it - /// on [_browserHistory]. - /// - /// This is only a convenience for testing. Apps will use JS-interop to set - /// their [UrlStrategy]. - @visibleForTesting - Future debugConvertAndSetUrlStrategy(UrlStrategy? strategy) { - return setUrlStrategy(convertToJsUrlStrategy(strategy)); - } - @override ui.VoidCallback? get onTextScaleFactorChanged => _onTextScaleFactorChanged; ui.VoidCallback? _onTextScaleFactorChanged; @@ -493,8 +447,8 @@ class EngineWindow extends ui.Window { /// Engine code should use this method instead of the callback directly. /// Otherwise zones won't work properly. - void invokeOnPlatformMessage( - String name, ByteData? data, ui.PlatformMessageResponseCallback callback) { + void invokeOnPlatformMessage(String name, ByteData? data, + ui.PlatformMessageResponseCallback callback) { _invoke3( _onPlatformMessage, _onPlatformMessageZone, @@ -516,7 +470,9 @@ class EngineWindow extends ui.Window { /// Wraps the given [callback] in another callback that ensures that the /// original callback is called in the zone it was registered in. - static ui.PlatformMessageResponseCallback? _zonedPlatformMessageResponseCallback(ui.PlatformMessageResponseCallback? callback) { + static ui.PlatformMessageResponseCallback? + _zonedPlatformMessageResponseCallback( + ui.PlatformMessageResponseCallback? callback) { if (callback == null) { return null; } @@ -580,7 +536,7 @@ class EngineWindow extends ui.Window { final MethodCall decoded = codec.decodeMethodCall(data); switch (decoded.method) { case 'SystemNavigator.pop': - _browserHistory.exit().then((_) { + browserHistory.exit().then((_) { _replyToPlatformMessage( callback, codec.encodeSuccessEnvelope(true)); }); @@ -601,8 +557,8 @@ class EngineWindow extends ui.Window { case 'SystemChrome.setPreferredOrientations': final List? arguments = decoded.arguments; domRenderer.setPreferredOrientation(arguments).then((bool success) { - _replyToPlatformMessage(callback, - codec.encodeSuccessEnvelope(success)); + _replyToPlatformMessage( + callback, codec.encodeSuccessEnvelope(success)); }); return; case 'SystemSound.play': @@ -648,7 +604,8 @@ class EngineWindow extends ui.Window { case 'flutter/platform_views': if (experimentalUseSkia) { - rasterizer!.surface.viewEmbedder.handlePlatformViewCall(data, callback); + rasterizer!.surface.viewEmbedder + .handlePlatformViewCall(data, callback); } else { ui.handlePlatformViewCall(data!, callback!); } @@ -685,14 +642,29 @@ class EngineWindow extends ui.Window { _replyToPlatformMessage(callback, null); } - bool _isHistoryInitialized = false; + @visibleForTesting + Future debugInitializeHistory( + UrlStrategy? strategy, { + bool? useSingle, + }) async { + await _browserHistory?.tearDown(); + switch (useSingle) { + case true: + _browserHistory = SingleEntryBrowserHistory(urlStrategy: strategy); + break; + case false: + _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); + break; + default: + _browserHistory = BrowserHistory.defaultImpl(urlStrategy: strategy); + break; + } + } @visibleForTesting Future debugResetHistory() async { - _isHistoryInitialized = false; - urlStrategy = null; - await _browserHistory.setUrlStrategy(null); - _browserHistory = MultiEntriesBrowserHistory(); + await _browserHistory?.tearDown(); + _browserHistory = null; } Future _handleNavigationMessage( @@ -703,19 +675,15 @@ class EngineWindow extends ui.Window { final MethodCall decoded = codec.decodeMethodCall(data); final Map arguments = decoded.arguments; - if (!_isHistoryInitialized) { - await setUrlStrategy(urlStrategy); - } - switch (decoded.method) { case 'routeUpdated': await _useSingleEntryBrowserHistory(); - _browserHistory.setRouteName(arguments['routeName']); + browserHistory.setRouteName(arguments['routeName']); _replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true)); return true; case 'routeInformationUpdated': - assert(_browserHistory is MultiEntriesBrowserHistory); - _browserHistory.setRouteName( + assert(browserHistory is MultiEntriesBrowserHistory); + browserHistory.setRouteName( arguments['location'], state: arguments['state'], ); @@ -786,7 +754,8 @@ class EngineWindow extends ui.Window { : ui.Brightness.light); _brightnessMediaQueryListener = (html.Event event) { - final html.MediaQueryListEvent mqEvent = event as html.MediaQueryListEvent; + final html.MediaQueryListEvent mqEvent = + event as html.MediaQueryListEvent; _updatePlatformBrightness( mqEvent.matches! ? ui.Brightness.dark : ui.Brightness.light); }; @@ -797,9 +766,17 @@ class EngineWindow extends ui.Window { } void _addUrlStrategyListener() { - _onUrlStrategy = allowInterop(setUrlStrategy); + _jsSetUrlStrategy = allowInterop((JsUrlStrategy? jsStrategy) { + assert( + _browserHistory == null, + 'Cannot set URL strategy more than once.', + ); + final UrlStrategy? strategy = + jsStrategy == null ? null : CustomUrlStrategy.fromJs(jsStrategy); + _browserHistory = BrowserHistory.defaultImpl(urlStrategy: strategy); + }); registerHotRestartListener(() { - _onUrlStrategy = null; + _jsSetUrlStrategy = null; }); } @@ -832,7 +809,8 @@ class EngineWindow extends ui.Window { } @visibleForTesting - late Rasterizer? rasterizer = experimentalUseSkia ? Rasterizer(Surface(HtmlViewEmbedder())) : null; + late Rasterizer? rasterizer = + experimentalUseSkia ? Rasterizer(Surface(HtmlViewEmbedder())) : null; } bool _handleWebTestEnd2EndMessage(MethodCodec codec, ByteData? data) { @@ -878,8 +856,8 @@ void _invoke1(void callback(A a)?, Zone? zone, A arg) { } /// Invokes [callback] inside the given [zone] passing it [arg1], [arg2], and [arg3]. -void _invoke3( - void callback(A1 a1, A2 a2, A3 a3)?, Zone? zone, A1 arg1, A2 arg2, A3 arg3) { +void _invoke3(void callback(A1 a1, A2 a2, A3 a3)?, Zone? zone, + A1 arg1, A2 arg2, A3 arg3) { if (callback == null) { return; } diff --git a/lib/web_ui/test/engine/history_test.dart b/lib/web_ui/test/engine/history_test.dart index 9f8593e0c2b9b..f5be42ae61db7 100644 --- a/lib/web_ui/test/engine/history_test.dart +++ b/lib/web_ui/test/engine/history_test.dart @@ -16,13 +16,6 @@ import 'package:ui/src/engine.dart'; import '../spy.dart'; -TestUrlStrategy _strategy; -TestUrlStrategy get strategy => _strategy; -Future setStrategy(TestUrlStrategy newStrategy) async { - _strategy = newStrategy; - await window.debugConvertAndSetUrlStrategy(newStrategy); -} - Map _wrapOriginState(dynamic state) { return {'origin': true, 'state': state}; } @@ -50,7 +43,6 @@ void testMain() { final PlatformMessagesSpy spy = PlatformMessagesSpy(); setUp(() async { - await window.debugSwitchBrowserHistory(useSingle: true); spy.setUp(); }); @@ -60,8 +52,10 @@ void testMain() { }); test('basic setup works', () async { - await setStrategy(TestUrlStrategy.fromEntry( - TestHistoryEntry('initial state', null, '/initial'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/initial'), + ); + await window.debugInitializeHistory(strategy, useSingle: true); // There should be two entries: origin and flutter. expect(strategy.history, hasLength(2)); @@ -84,7 +78,11 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('browser back button pops routes correctly', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry(null, null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: true); + // Initially, we should be on the flutter entry. expect(strategy.history, hasLength(2)); expect(strategy.currentEntry.state, flutterState); @@ -117,7 +115,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('multiple browser back clicks', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry(null, null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: true); await routeUpdated('/page1'); await routeUpdated('/page2'); @@ -183,7 +184,10 @@ void testMain() { browserEngine == BrowserEngine.webkit); test('handle user-provided url', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry(null, null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: true); await strategy.simulateUserTypingUrl('/page3'); // This delay is necessary to wait for [BrowserHistory] because it @@ -223,7 +227,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('user types unknown url', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry(null, null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry(null, null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: true); await strategy.simulateUserTypingUrl('/unknown'); // This delay is necessary to wait for [BrowserHistory] because it @@ -250,7 +257,6 @@ void testMain() { final PlatformMessagesSpy spy = PlatformMessagesSpy(); setUp(() async { - await window.debugSwitchBrowserHistory(useSingle: false); spy.setUp(); }); @@ -260,8 +266,10 @@ void testMain() { }); test('basic setup works', () async { - await setStrategy(TestUrlStrategy.fromEntry( - TestHistoryEntry('initial state', null, '/initial'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/initial'), + ); + await window.debugInitializeHistory(strategy, useSingle: false); // There should be only one entry. expect(strategy.history, hasLength(1)); @@ -275,7 +283,11 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('browser back button push route infromation correctly', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: false); + // Initially, we should be on the flutter entry. expect(strategy.history, hasLength(1)); expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0)); @@ -312,7 +324,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('multiple browser back clicks', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: false); await routeInfomrationUpdated('/page1', 'page1 state'); await routeInfomrationUpdated('/page2', 'page2 state'); @@ -361,7 +376,10 @@ void testMain() { browserEngine == BrowserEngine.webkit); test('handle user-provided url', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: false); await strategy.simulateUserTypingUrl('/page3'); // This delay is necessary to wait for [BrowserHistory] because it @@ -403,7 +421,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('forward button works', () async { - await setStrategy(TestUrlStrategy.fromEntry(TestHistoryEntry('initial state', null, '/home'))); + final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/home'), + ); + await window.debugInitializeHistory(strategy, useSingle: false); await routeInfomrationUpdated('/page1', 'page1 state'); await routeInfomrationUpdated('/page2', 'page2 state'); @@ -532,12 +553,12 @@ class TestPlatformLocation extends PlatformLocation { dynamic state; @override - void onPopState(html.EventListener fn) { + void addPopStateListener(html.EventListener fn) { throw UnimplementedError(); } @override - void offPopState(html.EventListener fn) { + void removePopStateListener(html.EventListener fn) { throw UnimplementedError(); } diff --git a/lib/web_ui/test/engine/navigation_test.dart b/lib/web_ui/test/engine/navigation_test.dart index e2ce89519441d..0b6e177824077 100644 --- a/lib/web_ui/test/engine/navigation_test.dart +++ b/lib/web_ui/test/engine/navigation_test.dart @@ -23,7 +23,7 @@ void main() { void testMain() { setUp(() async { _strategy = engine.TestUrlStrategy(); - await engine.window.debugConvertAndSetUrlStrategy(_strategy); + await engine.window.debugInitializeHistory(_strategy); }); tearDown(() async { diff --git a/lib/web_ui/test/window_test.dart b/lib/web_ui/test/window_test.dart index a03888bc440f9..ef0a755f550cf 100644 --- a/lib/web_ui/test/window_test.dart +++ b/lib/web_ui/test/window_test.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. // @dart = 2.6 -import 'dart:async'; import 'dart:html' as html; import 'dart:js_util' as js_util; import 'dart:typed_data'; @@ -12,6 +11,9 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; +import 'engine/history_test.dart'; +import 'matchers.dart'; + const MethodCodec codec = JSONMethodCodec(); void emptyCallback(ByteData data) {} @@ -21,10 +23,6 @@ void main() { } void testMain() { - setUp(() async { - await window.debugSwitchBrowserHistory(useSingle: true); - }); - tearDown(() async { await window.debugResetHistory(); }); @@ -33,7 +31,7 @@ void testMain() { final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( TestHistoryEntry('initial state', null, '/initial'), ); - await window.debugConvertAndSetUrlStrategy(strategy); + await window.debugInitializeHistory(strategy, useSingle: true); expect(window.defaultRouteName, '/initial'); // Changing the URL in the address bar later shouldn't affect [window.defaultRouteName]. @@ -43,9 +41,9 @@ void testMain() { test('window.defaultRouteName should reset after navigation platform message', () async { - await window.debugConvertAndSetUrlStrategy(TestUrlStrategy.fromEntry( + await window.debugInitializeHistory(TestUrlStrategy.fromEntry( TestHistoryEntry('initial state', null, '/initial'), - )); + ), useSingle: true); // Reading it multiple times should return the same value. expect(window.defaultRouteName, '/initial'); expect(window.defaultRouteName, '/initial'); @@ -63,35 +61,19 @@ void testMain() { }); test('can disable location strategy', () async { - final testStrategy = TestUrlStrategy.fromEntry( - TestHistoryEntry('initial state', null, '/'), - ); - await window.debugConvertAndSetUrlStrategy(testStrategy); - - expect(window.urlStrategy, isNotNull); - // 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, '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. + // Disable URL strategy. expect(() => jsSetUrlStrategy(null), returnsNormally); - // The url strategy is teared down asynchronously. - await Future.delayed(Duration.zero); - expect(window.urlStrategy, isNull); - - // The listener is removed asynchronously. - await Future.delayed(const Duration(milliseconds: 10)); - - // No more listeners. - expect(testStrategy.listeners, isEmpty); - // History should've moved back to the initial state. - expect(testStrategy.history[0].state, "initial state"); - expect(testStrategy.currentEntry, testStrategy.history[0]); + // History should be initialized. + expect(window.browserHistory, isNotNull); + // But without a URL strategy. + expect(window.browserHistory.urlStrategy, isNull); + // Current path is always "/" in this case. + expect(window.browserHistory.currentPath, '/'); + + // Perform some navigation operations. + routeInfomrationUpdated('/foo/bar', null); + // Path should not be updated because URL strategy is disabled. + expect(window.browserHistory.currentPath, '/'); }); test('js interop throws on wrong type', () { @@ -99,6 +81,22 @@ void testMain() { expect(() => jsSetUrlStrategy('foo'), throwsA(anything)); expect(() => jsSetUrlStrategy(false), throwsA(anything)); }); + + test('cannot set url strategy after it is initialized', () async { + final testStrategy = TestUrlStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/'), + ); + await window.debugInitializeHistory(testStrategy, useSingle: true); + + expect(() => jsSetUrlStrategy(null), throwsA(isAssertionError)); + }); + + test('cannot set url strategy more than once', () async { + // First time is okay. + expect(() => jsSetUrlStrategy(null), returnsNormally); + // Second time is not allowed. + expect(() => jsSetUrlStrategy(null), throwsA(isAssertionError)); + }); } void jsSetUrlStrategy(dynamic strategy) { From 9b8e037b1ca44c73f0f4744aa738aa31768a0004 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Mon, 5 Oct 2020 14:46:07 -0700 Subject: [PATCH 6/7] address remaining comments --- lib/web_ui/lib/src/engine.dart | 6 ++-- .../{history => navigation}/history.dart | 24 +++++-------- .../js_url_strategy.dart | 18 ---------- .../{history => navigation}/url_strategy.dart | 35 ++++++++++--------- lib/web_ui/lib/src/engine/window.dart | 23 +++++------- 5 files changed, 38 insertions(+), 68 deletions(-) rename lib/web_ui/lib/src/engine/{history => navigation}/history.dart (94%) rename lib/web_ui/lib/src/engine/{history => navigation}/js_url_strategy.dart (80%) rename lib/web_ui/lib/src/engine/{history => navigation}/url_strategy.dart (88%) diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index 21be03c6ac117..dcb19c8a3165f 100644 --- a/lib/web_ui/lib/src/engine.dart +++ b/lib/web_ui/lib/src/engine.dart @@ -62,9 +62,9 @@ part 'engine/dom_canvas.dart'; part 'engine/dom_renderer.dart'; part 'engine/engine_canvas.dart'; part 'engine/frame_reference.dart'; -part 'engine/history/history.dart'; -part 'engine/history/js_url_strategy.dart'; -part 'engine/history/url_strategy.dart'; +part 'engine/navigation/history.dart'; +part 'engine/navigation/js_url_strategy.dart'; +part 'engine/navigation/url_strategy.dart'; part 'engine/html/backdrop_filter.dart'; part 'engine/html/canvas.dart'; part 'engine/html/clip.dart'; diff --git a/lib/web_ui/lib/src/engine/history/history.dart b/lib/web_ui/lib/src/engine/navigation/history.dart similarity index 94% rename from lib/web_ui/lib/src/engine/history/history.dart rename to lib/web_ui/lib/src/engine/navigation/history.dart index 72c2e60d6a7ac..0a578162a9096 100644 --- a/lib/web_ui/lib/src/engine/history/history.dart +++ b/lib/web_ui/lib/src/engine/navigation/history.dart @@ -22,10 +22,6 @@ part of engine; /// * [MultiEntriesBrowserHistory]: which creates a set of states that records /// the navigating events happened in the framework. abstract class BrowserHistory { - static BrowserHistory defaultImpl({required UrlStrategy? urlStrategy}) { - return MultiEntriesBrowserHistory(urlStrategy: urlStrategy); - } - late ui.VoidCallback _unsubscribe; /// The strategy to interact with html browser history. @@ -50,11 +46,8 @@ abstract class BrowserHistory { } /// This method does the same thing as the browser back button. - Future back() { - if (urlStrategy != null) { - return urlStrategy!.go(-1); - } - return Future.value(); + Future back() async { + return urlStrategy?.go(-1); } /// The path of the current location of the user's browser. @@ -120,7 +113,7 @@ class MultiEntriesBrowserHistory extends BrowserHistory { return 0; } - Object? _tagWithSerialCount(Object? originialState, int count) { + Object _tagWithSerialCount(Object? originialState, int count) { return { 'serialCount': count, 'state': originialState, @@ -223,12 +216,11 @@ class SingleEntryBrowserHistory extends BrowserHistory { _setupStrategy(strategy); final String path = currentPath; - if (_isFlutterEntry(html.window.history.state)) { - // This could happen if the user, for example, refreshes the page. They - // will land directly on the "flutter" entry, so there's no need to setup - // the "origin" and "flutter" entries, we can safely assume they are - // already setup. - } else { + if (!_isFlutterEntry(html.window.history.state)) { + // An entry may not have come from Flutter, for example, when the user + // refreshes the page. They land directly on the "flutter" entry, so + // there's no need to setup the "origin" and "flutter" entries, we can + // safely assume they are already setup. _setupOriginEntry(strategy); _setupFlutterEntry(strategy, replace: false, path: path); } diff --git a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart b/lib/web_ui/lib/src/engine/navigation/js_url_strategy.dart similarity index 80% rename from lib/web_ui/lib/src/engine/history/js_url_strategy.dart rename to lib/web_ui/lib/src/engine/navigation/js_url_strategy.dart index 7c13fab8cce05..decb7c249d44d 100644 --- a/lib/web_ui/lib/src/engine/history/js_url_strategy.dart +++ b/lib/web_ui/lib/src/engine/navigation/js_url_strategy.dart @@ -18,24 +18,6 @@ typedef _StateOperation = void Function( typedef _HistoryMove = Future Function(int count); -/// Given a Dart implementation of URL strategy, it converts it to a JavaScript -/// URL strategy that be passed through JS interop. -JsUrlStrategy? convertToJsUrlStrategy(UrlStrategy? strategy) { - if (strategy == null) { - return null; - } - - return JsUrlStrategy( - getPath: allowInterop(strategy.getPath), - getState: allowInterop(strategy.getState), - addPopStateListener: allowInterop(strategy.addPopStateListener), - prepareExternalUrl: allowInterop(strategy.prepareExternalUrl), - pushState: allowInterop(strategy.pushState), - replaceState: allowInterop(strategy.replaceState), - go: allowInterop(strategy.go), - ); -} - /// The JavaScript representation of a URL strategy. /// /// This is used to pass URL strategy implementations across a JS-interop diff --git a/lib/web_ui/lib/src/engine/history/url_strategy.dart b/lib/web_ui/lib/src/engine/navigation/url_strategy.dart similarity index 88% rename from lib/web_ui/lib/src/engine/history/url_strategy.dart rename to lib/web_ui/lib/src/engine/navigation/url_strategy.dart index 9822dec10b947..fcf2cecfd0b8e 100644 --- a/lib/web_ui/lib/src/engine/history/url_strategy.dart +++ b/lib/web_ui/lib/src/engine/navigation/url_strategy.dart @@ -9,14 +9,13 @@ part of engine; /// /// By default, the [HashUrlStrategy] subclass is used if the app doesn't /// specify one. -/// -/// This is used by [BrowserHistory] to interact with browser history APIs. abstract class UrlStrategy { - /// This constructor is here only to allow subclasses to be const. + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. const UrlStrategy(); - /// Subscribes to popstate events and returns a function that could be used to - /// unsubscribe from popstate events. + /// Adds a listener to the `popstate` event and returns a function that, when + /// invoked, removes the listener. ui.VoidCallback addPopStateListener(html.EventListener fn); /// Returns the active path in the browser. @@ -70,8 +69,8 @@ abstract class UrlStrategy { class HashUrlStrategy extends UrlStrategy { /// Creates an instance of [HashUrlStrategy]. /// - /// The [PlatformLocation] parameter is useful for testing to avoid - /// interacting with the actual browser. + /// The [PlatformLocation] parameter is useful for testing to mock out browser + /// interations. const HashUrlStrategy( [this._platformLocation = const BrowserPlatformLocation()]); @@ -130,7 +129,7 @@ class HashUrlStrategy extends UrlStrategy { /// Waits until the next popstate event is fired. /// - /// This is useful for example to wait until the browser has handled the + /// This is useful, for example, to wait until the browser has handled the /// `history.back` transition. Future _waitForPopState() { final Completer completer = Completer(); @@ -146,6 +145,7 @@ class HashUrlStrategy extends UrlStrategy { /// Wraps a custom implementation of [UrlStrategy] that was previously converted /// to a [JsUrlStrategy]. class CustomUrlStrategy extends UrlStrategy { + /// Wraps the [delegate] in a [CustomUrlStrategy] instance. CustomUrlStrategy.fromJs(this.delegate); final JsUrlStrategy delegate; @@ -176,14 +176,14 @@ class CustomUrlStrategy extends UrlStrategy { Future go(int count) => delegate.go(count); } -/// [PlatformLocation] encapsulates all calls to DOM apis, which allows the -/// [UrlStrategy] classes to be platform agnostic and testable. +/// Encapsulates all calls to DOM apis, which allows the [UrlStrategy] classes +/// to be platform agnostic and testable. /// -/// The [PlatformLocation] class is used directly by all implementations of -/// [UrlStrategy] when they need to interact with the DOM apis like -/// pushState, popState, etc. +/// For convenience, the [PlatformLocation] class can be used by implementations +/// of [UrlStrategy] to interact with DOM apis like pushState, popState, etc. abstract class PlatformLocation { - /// This constructor is here only to allow subclasses to be const. + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. const PlatformLocation(); /// Registers an event listener for the `popstate` event. @@ -191,7 +191,8 @@ abstract class PlatformLocation { /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate void addPopStateListener(html.EventListener fn); - /// Unregisters the given listener from the `popstate` event. + /// Unregisters the given listener (added by [addPopStateListener]) from the + /// `popstate` event. /// /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate void removePopStateListener(html.EventListener fn); @@ -208,7 +209,7 @@ abstract class PlatformLocation { /// The `hash]` part of the URL in the browser address bar. /// - /// See: ttps://developer.mozilla.org/en-US/docs/Web/API/Location/hash + /// See: https://developer.mozilla.org/en-US/docs/Web/API/Location/hash String? get hash; /// The `state` in the current history entry. @@ -245,7 +246,7 @@ abstract class PlatformLocation { String? getBaseHref(); } -/// An implementation of [PlatformLocation] for the browser. +/// Delegates to real browser APIs to provide platform location functionality. class BrowserPlatformLocation extends PlatformLocation { /// Default constructor for [BrowserPlatformLocation]. const BrowserPlatformLocation(); diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 5ded294d50e97..9424a0c69e182 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -17,8 +17,9 @@ typedef _JsSetUrlStrategy = void Function(JsUrlStrategy?); /// A JavaScript hook to customize the URL strategy of a Flutter app. // -// KEEP THIS JS NAME IN SYNC WITH flutter_web_plugins. -// Find it at: https://github.com/flutter/flutter/blob/custom_location_strategy/packages/flutter_web_plugins/lib/src/history/js_url_strategy.dart +// Keep this js name in sync with flutter_web_plugins. Find it at: +// https://github.com/flutter/flutter/blob/custom_location_strategy/packages/flutter_web_plugins/lib/src/navigation/js_url_strategy.dart +// // TODO: Add integration test https://github.com/flutter/flutter/issues/66852 @JS('_flutter_web_set_location_strategy') external set _jsSetUrlStrategy(_JsSetUrlStrategy? newJsSetUrlStrategy); @@ -163,7 +164,7 @@ class EngineWindow extends ui.Window { @visibleForTesting BrowserHistory get browserHistory { return _browserHistory ??= - BrowserHistory.defaultImpl(urlStrategy: const HashUrlStrategy()); + MultiEntriesBrowserHistory(urlStrategy: const HashUrlStrategy()); } BrowserHistory? _browserHistory; @@ -648,16 +649,10 @@ class EngineWindow extends ui.Window { bool? useSingle, }) async { await _browserHistory?.tearDown(); - switch (useSingle) { - case true: - _browserHistory = SingleEntryBrowserHistory(urlStrategy: strategy); - break; - case false: - _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); - break; - default: - _browserHistory = BrowserHistory.defaultImpl(urlStrategy: strategy); - break; + if (useSingle == true) { + _browserHistory = SingleEntryBrowserHistory(urlStrategy: strategy); + } else { + _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); } } @@ -773,7 +768,7 @@ class EngineWindow extends ui.Window { ); final UrlStrategy? strategy = jsStrategy == null ? null : CustomUrlStrategy.fromJs(jsStrategy); - _browserHistory = BrowserHistory.defaultImpl(urlStrategy: strategy); + _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); }); registerHotRestartListener(() { _jsSetUrlStrategy = null; From ed453290e74510eef271bcd3cffcf132438e3e77 Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Wed, 7 Oct 2020 10:49:01 -0700 Subject: [PATCH 7/7] make useSingle required --- lib/web_ui/lib/src/engine/window.dart | 4 ++-- lib/web_ui/test/engine/navigation_test.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 9424a0c69e182..757d85ee4151e 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -646,10 +646,10 @@ class EngineWindow extends ui.Window { @visibleForTesting Future debugInitializeHistory( UrlStrategy? strategy, { - bool? useSingle, + required bool useSingle, }) async { await _browserHistory?.tearDown(); - if (useSingle == true) { + if (useSingle) { _browserHistory = SingleEntryBrowserHistory(urlStrategy: strategy); } else { _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); diff --git a/lib/web_ui/test/engine/navigation_test.dart b/lib/web_ui/test/engine/navigation_test.dart index 0b6e177824077..99bae818f54a7 100644 --- a/lib/web_ui/test/engine/navigation_test.dart +++ b/lib/web_ui/test/engine/navigation_test.dart @@ -23,7 +23,7 @@ void main() { void testMain() { setUp(() async { _strategy = engine.TestUrlStrategy(); - await engine.window.debugInitializeHistory(_strategy); + await engine.window.debugInitializeHistory(_strategy, useSingle: true); }); tearDown(() async {