diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index ed74366279e9468056eef0e5a67b81a0c438f062..72bfafac4c963f33e1028c446859ab663a0fcb5a 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 diff --git a/lib/web_ui/lib/src/engine.dart b/lib/web_ui/lib/src/engine.dart index d8c01373840f8408f27a701b5c8e5e066b7b28ea..dcb19c8a3165f417f5bef004a8468b5392ea299c 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/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/browser_location.dart b/lib/web_ui/lib/src/engine/browser_location.dart deleted file mode 100644 index a9701cd99060f645ef452a14cf09ce16eb8ba143..0000000000000000000000000000000000000000 --- a/lib/web_ui/lib/src/engine/browser_location.dart +++ /dev/null @@ -1,211 +0,0 @@ -// 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; - -// 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 -/// from the browser's URL. -/// -/// At the moment, only one strategy is implemented: [HashLocationStrategy]. -/// -/// This is used by [BrowserHistory] to interact with browser history APIs. -abstract class LocationStrategy { - const LocationStrategy(); - - /// 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; - - /// The state of the current browser history entry. - dynamic get state; - - /// Given a path that's internal to the app, create the external url that - /// will be used in the browser. - String prepareExternalUrl(String internalUrl); - - /// Push a new history entry. - void pushState(dynamic state, String title, String url); - - /// 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}); -} - -/// This is an implementation of [LocationStrategy] 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]: -/// -/// ```dart -/// import 'package:flutter_web/material.dart'; -/// import 'package:flutter_web/ui.dart' as ui; -/// -/// void main() { -/// ui.window.locationStrategy = const ui.HashLocationStrategy(); -/// runApp(MyApp()); -/// } -/// ``` -class HashLocationStrategy extends LocationStrategy { - final PlatformLocation _platformLocation; - - const HashLocationStrategy( - [this._platformLocation = const BrowserPlatformLocation()]); - - @override - ui.VoidCallback onPopState(html.EventListener fn) { - _platformLocation.onPopState(fn); - return () => _platformLocation.offPopState(fn); - } - - @override - String get path { - // the hash value is always prefixed with a `#` - // and if it is empty then it will stay empty - 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 "/". - if (path.isEmpty || path == '#') { - return '/'; - } - // At this point, we know [path] starts with "#" and isn't empty. - return path.substring(1); - } - - @override - dynamic get state => _platformLocation.state; - - @override - String prepareExternalUrl(String internalUrl) { - // It's convention that if the hash path is empty, we omit the `#`; however, - // if the empty URL is pushed it won't replace any existing fragment. So - // when the hash path is empty, we instead return the location's path and - // query. - return internalUrl.isEmpty - ? '${_platformLocation.pathname}${_platformLocation.search}' - : '#$internalUrl'; - } - - @override - void pushState(dynamic state, String title, String url) { - _platformLocation.pushState(state, title, prepareExternalUrl(url)); - } - - @override - void replaceState(dynamic state, String title, String url) { - _platformLocation.replaceState(state, title, prepareExternalUrl(url)); - } - - @override - Future back({int count = 1}) { - _platformLocation.back(count); - return _waitForPopState(); - } - - /// Waits until the next popstate event is fired. - /// - /// This is useful for example to wait until the browser has handled the - /// `history.back` transition. - Future _waitForPopState() { - final Completer completer = Completer(); - late ui.VoidCallback unsubscribe; - unsubscribe = onPopState((_) { - unsubscribe(); - completer.complete(); - }); - return completer.future; - } -} - -/// [PlatformLocation] encapsulates all calls to DOM apis, which allows the -/// [LocationStrategy] 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 -/// pushState, popState, etc... -abstract class PlatformLocation { - const PlatformLocation(); - - void onPopState(html.EventListener fn); - void offPopState(html.EventListener fn); - - void onHashChange(html.EventListener fn); - void offHashChange(html.EventListener fn); - - String get pathname; - String get search; - String? get hash; - dynamic get state; - - void pushState(dynamic state, String title, String url); - void replaceState(dynamic state, String title, String url); - void back(int count); -} - -/// An implementation of [PlatformLocation] for the browser. -class BrowserPlatformLocation extends PlatformLocation { - 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); - } - - @override - void offPopState(html.EventListener fn) { - 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!; - - @override - String get search => _location.search!; - - @override - String get hash => _location.hash; - - @override - dynamic get state => _history.state; - - @override - void pushState(dynamic state, String title, String url) { - _history.pushState(state, title, url); - } - - @override - void replaceState(dynamic state, String title, String url) { - _history.replaceState(state, title, url); - } - - @override - void back(int count) { - _history.go(-count); - } -} diff --git a/lib/web_ui/lib/src/engine/history.dart b/lib/web_ui/lib/src/engine/navigation/history.dart similarity index 69% rename from lib/web_ui/lib/src/engine/history.dart rename to lib/web_ui/lib/src/engine/navigation/history.dart index 59e1ba5fddf6ded943c01942225bf70e55a3021c..0a578162a9096ea81c984572bc18fff16aa1b88f 100644 --- a/lib/web_ui/lib/src/engine/history.dart +++ b/lib/web_ui/lib/src/engine/navigation/history.dart @@ -25,64 +25,39 @@ abstract class BrowserHistory { late ui.VoidCallback _unsubscribe; /// The strategy to interact with html browser history. - LocationStrategy? get locationStrategy => _locationStrategy; - LocationStrategy? _locationStrategy; - /// Updates the strategy. - /// - /// This method will also remove any previous modifications to the html - /// browser history and start anew. - Future setLocationStrategy(LocationStrategy? strategy) async { - if (strategy != _locationStrategy) { - await _tearoffStrategy(_locationStrategy); - _locationStrategy = strategy; - await _setupStrategy(_locationStrategy); - } - } + UrlStrategy? get urlStrategy; - Future _setupStrategy(LocationStrategy? strategy) async { - if (strategy == null) { - return; - } - _unsubscribe = strategy.onPopState(onPopState as dynamic Function(html.Event)); - await setup(); - } + bool _isDisposed = false; - Future _tearoffStrategy(LocationStrategy? strategy) async { - if (strategy == null) { - return; - } - _unsubscribe(); - - 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 (_locationStrategy != null) { - await _tearoffStrategy(_locationStrategy); + if (urlStrategy != null) { + await tearDown(); // 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); } } /// This method does the same thing as the browser back button. - Future back() { - if (locationStrategy != null) { - return locationStrategy!.back(); - } - return Future.value(); + Future back() async { + return urlStrategy?.go(-1); } /// 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; + 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 +65,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,31 +85,51 @@ 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}) { - if (locationStrategy != null) { + void setRouteName(String? routeName, {Object? state}) { + if (urlStrategy != null) { assert(routeName != null); _lastSeenSerialCount += 1; - locationStrategy!.pushState( + urlStrategy!.pushState( _tagWithSerialCount(state, _lastSeenSerialCount), 'flutter', routeName!, @@ -147,58 +139,51 @@ 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( - _tagWithSerialCount(event.state, _lastSeenSerialCount + 1), - 'flutter', - currentPath); + urlStrategy!.replaceState( + _tagWithSerialCount(event.state, _lastSeenSerialCount + 1), + 'flutter', + currentPath); } _lastSeenSerialCount = _currentSerialCount; if (window._onPlatformMessage != null) { window.invokeOnPlatformMessage( 'flutter/navigation', const JSONMethodCodec().encodeMethodCall( - MethodCall('pushRouteInformation', { - 'location': currentPath, - 'state': event.state?['state'], - }) - ), + MethodCall('pushRouteInformation', { + 'location': currentPath, + 'state': event.state?['state'], + })), (_) {}, ); } } @override - Future setup() { - if (!_hasSerialCount(currentState)) { - locationStrategy!.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; if (backCount > 0) { - await locationStrategy!.back(count: backCount); + await urlStrategy!.go(-backCount); } // Unwrap state. assert(_hasSerialCount(currentState) && _currentSerialCount == 0); - locationStrategy!.replaceState( - currentState['state'], + final Map stateMap = + currentState as Map; + urlStrategy!.replaceState( + stateMap['state'], 'flutter', currentPath, ); @@ -222,37 +207,61 @@ 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)) { + // 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); + } + } + + @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}) { - if (locationStrategy != null) { - _setupFlutterEntry(locationStrategy!, replace: true, path: routeName); + void setRouteName(String? routeName, {Object? state}) { + if (urlStrategy != null) { + _setupFlutterEntry(urlStrategy!, replace: true, path: routeName); } } @@ -260,7 +269,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 +311,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(UrlStrategy strategy) { assert(strategy != null); // ignore: unnecessary_null_comparison strategy.replaceState(_wrapOriginState(currentState), 'origin', ''); } @@ -317,7 +326,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, { + UrlStrategy strategy, { bool replace = false, String? path, }) { @@ -330,28 +339,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(locationStrategy!); - _setupFlutterEntry(locationStrategy!, replace: false, path: path); - } - return Future.value(); - } - @override Future tearDown() async { - if (locationStrategy != null) { - // We need to remove the flutter entry that we pushed in setup. - await locationStrategy!.back(); - // Restores original state. - locationStrategy!.replaceState(_unwrapOriginState(currentState), 'flutter', currentPath); + 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/navigation/js_url_strategy.dart b/lib/web_ui/lib/src/engine/navigation/js_url_strategy.dart new file mode 100644 index 0000000000000000000000000000000000000000..decb7c249d44db8a3ab5b28c25dadb50e355989a --- /dev/null +++ b/lib/web_ui/lib/src/engine/navigation/js_url_strategy.dart @@ -0,0 +1,78 @@ +// 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 = Object? Function(); + +typedef _AddPopStateListener = ui.VoidCallback Function(html.EventListener); + +typedef _StringToString = String Function(String); + +typedef _StateOperation = void Function( + Object? state, String title, String url); + +typedef _HistoryMove = Future Function(int count); + +/// The JavaScript representation of a URL strategy. +/// +/// This is used to pass URL strategy implementations across a JS-interop +/// 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 _AddPopStateListener addPopStateListener, + required _StringToString prepareExternalUrl, + required _StateOperation pushState, + required _StateOperation replaceState, + required _HistoryMove go, + }); + + /// 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. + /// + /// 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. + /// + /// 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. + /// + /// 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/navigation/url_strategy.dart b/lib/web_ui/lib/src/engine/navigation/url_strategy.dart new file mode 100644 index 0000000000000000000000000000000000000000..fcf2cecfd0b8e6d639f13456dcff4315c006b0a2 --- /dev/null +++ b/lib/web_ui/lib/src/engine/navigation/url_strategy.dart @@ -0,0 +1,296 @@ +// 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; + +/// Represents and reads route state from the browser's URL. +/// +/// By default, the [HashUrlStrategy] subclass is used if the app doesn't +/// specify one. +abstract class UrlStrategy { + /// Abstract const constructor. This constructor enables subclasses to provide + /// const constructors so that they can be used in const expressions. + const UrlStrategy(); + + /// 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. + String getPath(); + + /// The state of the current browser history entry. + /// + /// 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. + /// + /// 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. + /// + /// 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); +} + +/// 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 [UrlStrategy] for an app, it needs to be set like this: +/// +/// ```dart +/// import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +/// +/// // Somewhere before calling `runApp()` do: +/// setUrlStrategy(const HashUrlStrategy()); +/// ``` +class HashUrlStrategy extends UrlStrategy { + /// Creates an instance of [HashUrlStrategy]. + /// + /// The [PlatformLocation] parameter is useful for testing to mock out browser + /// interations. + const HashUrlStrategy( + [this._platformLocation = const BrowserPlatformLocation()]); + + final PlatformLocation _platformLocation; + + @override + ui.VoidCallback addPopStateListener(html.EventListener fn) { + _platformLocation.addPopStateListener(fn); + return () => _platformLocation.removePopStateListener(fn); + } + + @override + String getPath() { + // the hash value is always prefixed with a `#` + // and if it is empty then it will stay empty + 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 "/". + if (path.isEmpty || path == '#') { + return '/'; + } + // At this point, we know [path] starts with "#" and isn't empty. + return path.substring(1); + } + + @override + Object? getState() => _platformLocation.state; + + @override + String prepareExternalUrl(String internalUrl) { + // It's convention that if the hash path is empty, we omit the `#`; however, + // if the empty URL is pushed it won't replace any existing fragment. So + // when the hash path is empty, we instead return the location's path and + // query. + return internalUrl.isEmpty + ? '${_platformLocation.pathname}${_platformLocation.search}' + : '#$internalUrl'; + } + + @override + void pushState(Object? state, String title, String url) { + _platformLocation.pushState(state, title, prepareExternalUrl(url)); + } + + @override + void replaceState(Object? state, String title, String url) { + _platformLocation.replaceState(state, title, prepareExternalUrl(url)); + } + + @override + Future go(int count) { + _platformLocation.go(count); + return _waitForPopState(); + } + + /// Waits until the next popstate event is fired. + /// + /// This is useful, for example, to wait until the browser has handled the + /// `history.back` transition. + Future _waitForPopState() { + final Completer completer = Completer(); + late ui.VoidCallback unsubscribe; + unsubscribe = addPopStateListener((_) { + unsubscribe(); + completer.complete(); + }); + return completer.future; + } +} + +/// 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; + + @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); +} + +/// Encapsulates all calls to DOM apis, which allows the [UrlStrategy] classes +/// to be platform agnostic and testable. +/// +/// For convenience, the [PlatformLocation] class can be used by implementations +/// of [UrlStrategy] to interact with DOM apis like pushState, popState, etc. +abstract class PlatformLocation { + /// 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. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate + void addPopStateListener(html.EventListener fn); + + /// 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); + + /// 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. + /// + /// 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: https://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 + 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(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(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 + void go(int count); + + /// The base href where the Flutter app is being served. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base + String? getBaseHref(); +} + +/// Delegates to real browser APIs to provide platform location functionality. +class BrowserPlatformLocation extends PlatformLocation { + /// Default constructor for [BrowserPlatformLocation]. + const BrowserPlatformLocation(); + + html.Location get _location => html.window.location; + html.History get _history => html.window.history; + + @override + void addPopStateListener(html.EventListener fn) { + html.window.addEventListener('popstate', fn); + } + + @override + void removePopStateListener(html.EventListener fn) { + html.window.removeEventListener('popstate', fn); + } + + @override + String get pathname => _location.pathname!; + + @override + String get search => _location.search!; + + @override + String get hash => _location.hash; + + @override + Object? get state => _history.state; + + @override + void pushState(Object? state, String title, String url) { + _history.pushState(state, title, url); + } + + @override + void replaceState(Object? state, String title, String url) { + _history.replaceState(state, title, url); + } + + @override + void go(int count) { + _history.go(count); + } + + @override + String? getBaseHref() => html.document.baseUri; +} diff --git a/lib/web_ui/lib/src/engine/test_embedding.dart b/lib/web_ui/lib/src/engine/test_embedding.dart index f0d3a4291dbadb541908a6cecde5259c6b92cfbe..0255e5fb19601366ed3fb43f60cfb253a652a294 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(); } @@ -124,7 +122,7 @@ class TestLocationStrategy extends LocationStrategy { 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 94d73f6c7d717356319ea9c8436760c8964dbd23..757d85ee4151e2fd8d516f817e9abe92efaca978 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -13,20 +13,27 @@ const bool _debugPrintPlatformMessages = false; /// This may be overridden in tests, for example, to pump fake frames. ui.VoidCallback? scheduleFrameCallback; +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/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); + /// 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 - double get devicePixelRatio => _debugDevicePixelRatio ?? browserDevicePixelRatio; + double get devicePixelRatio => + _debugDevicePixelRatio ?? browserDevicePixelRatio; /// Returns device pixel ratio returned by browser. static double get browserDevicePixelRatio { @@ -117,7 +124,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; @@ -126,7 +134,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 @@ -154,78 +162,41 @@ 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 LocationStrategy? strategy = _browserHistory.locationStrategy; - if (strategy != null) - await _browserHistory.setLocationStrategy(null); - _browserHistory = MultiEntriesBrowserHistory(); - if (strategy != null) - await _browserHistory.setLocationStrategy(strategy); + BrowserHistory get browserHistory { + return _browserHistory ??= + MultiEntriesBrowserHistory(urlStrategy: const HashUrlStrategy()); } + BrowserHistory? _browserHistory; + Future _useSingleEntryBrowserHistory() async { if (_browserHistory is SingleEntryBrowserHistory) { return; } - final LocationStrategy? strategy = _browserHistory.locationStrategy; - if (strategy != null) - await _browserHistory.setLocationStrategy(null); - _browserHistory = SingleEntryBrowserHistory(); - if (strategy != null) - await _browserHistory.setLocationStrategy(strategy); + 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 [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 { + 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!(); } - /// 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); - } - - /// Returns the currently active location strategy. - @visibleForTesting - LocationStrategy? get locationStrategy => _browserHistory.locationStrategy; - @override ui.VoidCallback? get onTextScaleFactorChanged => _onTextScaleFactorChanged; ui.VoidCallback? _onTextScaleFactorChanged; @@ -477,8 +448,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, @@ -500,7 +471,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; } @@ -564,7 +537,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)); }); @@ -585,8 +558,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': @@ -632,7 +605,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!); } @@ -646,27 +620,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 +643,51 @@ class EngineWindow extends ui.Window { _replyToPlatformMessage(callback, null); } + @visibleForTesting + Future debugInitializeHistory( + UrlStrategy? strategy, { + required bool useSingle, + }) async { + await _browserHistory?.tearDown(); + if (useSingle) { + _browserHistory = SingleEntryBrowserHistory(urlStrategy: strategy); + } else { + _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); + } + } + + @visibleForTesting + Future debugResetHistory() async { + await _browserHistory?.tearDown(); + _browserHistory = null; + } + + Future _handleNavigationMessage( + ByteData? data, + ui.PlatformMessageResponseCallback? callback, + ) async { + const MethodCodec codec = JSONMethodCodec(); + final MethodCall decoded = codec.decodeMethodCall(data); + final Map arguments = decoded.arguments; + + 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': @@ -746,7 +749,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); }; @@ -756,6 +760,21 @@ class EngineWindow extends ui.Window { }); } + void _addUrlStrategyListener() { + _jsSetUrlStrategy = allowInterop((JsUrlStrategy? jsStrategy) { + assert( + _browserHistory == null, + 'Cannot set URL strategy more than once.', + ); + final UrlStrategy? strategy = + jsStrategy == null ? null : CustomUrlStrategy.fromJs(jsStrategy); + _browserHistory = MultiEntriesBrowserHistory(urlStrategy: strategy); + }); + registerHotRestartListener(() { + _jsSetUrlStrategy = null; + }); + } + /// Remove the callback function for listening changes in [_brightnessMediaQuery] value. void _removeBrightnessMediaQueryListener() { _brightnessMediaQuery.removeListener(_brightnessMediaQueryListener); @@ -785,7 +804,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) { @@ -831,8 +851,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/lib/src/ui/initialization.dart b/lib/web_ui/lib/src/ui/initialization.dart index a7b06b3586defe20ffa50b4e8468ce5023445ef0..ca317304ec79ba5b9a1803c36db2cc940fb2d855 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 4c11ed0033636704ad5658a478651e99d6117aee..f5be42ae61db781eef6623977dd353c29726df54 100644 --- a/lib/web_ui/test/engine/history_test.dart +++ b/lib/web_ui/test/engine/history_test.dart @@ -16,11 +16,6 @@ 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); -} - Map _wrapOriginState(dynamic state) { return {'origin': true, 'state': state}; } @@ -48,18 +43,19 @@ void testMain() { final PlatformMessagesSpy spy = PlatformMessagesSpy(); setUp(() async { - await window.debugSwitchBrowserHistory(useSingle: true); spy.setUp(); }); tearDown(() async { spy.tearDown(); - await setStrategy(null); + await window.debugResetHistory(); }); test('basic setup works', () async { - await setStrategy(TestLocationStrategy.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)); @@ -82,7 +78,11 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('browser back button pops routes correctly', () async { - await setStrategy(TestLocationStrategy.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); @@ -98,7 +98,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 +115,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('multiple browser back clicks', () async { - await setStrategy(TestLocationStrategy.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'); @@ -127,7 +130,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 +146,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 +164,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 +184,10 @@ void testMain() { browserEngine == BrowserEngine.webkit); test('handle user-provided url', () async { - await setStrategy(TestLocationStrategy.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 @@ -202,7 +208,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 +227,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('user types unknown url', () async { - await setStrategy(TestLocationStrategy.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 @@ -248,18 +257,19 @@ void testMain() { final PlatformMessagesSpy spy = PlatformMessagesSpy(); setUp(() async { - await window.debugSwitchBrowserHistory(useSingle: false); spy.setUp(); }); tearDown(() async { spy.tearDown(); - await setStrategy(null); + await window.debugResetHistory(); }); test('basic setup works', () async { - await setStrategy(TestLocationStrategy.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)); @@ -273,7 +283,11 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('browser back button push route infromation correctly', () async { - await setStrategy(TestLocationStrategy.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)); @@ -289,7 +303,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 +324,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('multiple browser back clicks', () async { - await setStrategy(TestLocationStrategy.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'); @@ -322,7 +339,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 +355,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 +376,10 @@ void testMain() { browserEngine == BrowserEngine.webkit); test('handle user-provided url', () async { - await setStrategy(TestLocationStrategy.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 @@ -381,7 +401,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 +421,10 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('forward button works', () async { - await setStrategy(TestLocationStrategy.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'); @@ -413,7 +436,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 +453,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 +473,7 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); }); - group('$HashLocationStrategy', () { + group('$HashUrlStrategy', () { TestPlatformLocation location; setUp(() { @@ -462,26 +485,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 +552,31 @@ class TestPlatformLocation extends PlatformLocation { String hash; dynamic state; - void onPopState(html.EventListener fn) { + @override + void addPopStateListener(html.EventListener fn) { throw UnimplementedError(); } - void offPopState(html.EventListener fn) { - throw UnimplementedError(); - } - - void onHashChange(html.EventListener fn) { - throw UnimplementedError(); - } - - void offHashChange(html.EventListener fn) { + @override + void removePopStateListener(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(); } + + @override + String getBaseHref() => '/'; } diff --git a/lib/web_ui/test/engine/navigation_test.dart b/lib/web_ui/test/engine/navigation_test.dart index 44d3bf2939e9589f3e4f99fc5cf861f9492d5c5e..99bae818f54a7b17f67fed517836cb7ba79379e3 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.debugInitializeHistory(_strategy, useSingle: true); }); - 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 b83849bffc8d63197a971494c2c84b5f67ce49d9..ef0a755f550cfedc68766c219897b6c2434cf14c 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,34 +11,39 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; -const MethodCodec codec = JSONMethodCodec(); +import 'engine/history_test.dart'; +import 'matchers.dart'; -void emptyCallback(ByteData date) {} +const MethodCodec codec = JSONMethodCodec(); -Future setStrategy(TestLocationStrategy newStrategy) async { - await window.browserHistory.setLocationStrategy(newStrategy); -} +void emptyCallback(ByteData data) {} void main() { internalBootstrapBrowserTest(() => testMain); } void testMain() { - setUp(() async { - 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.debugInitializeHistory(strategy, useSingle: true); 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.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'); @@ -57,45 +61,45 @@ void testMain() { }); test('can disable location strategy', () async { - await window.debugSwitchBrowserHistory(useSingle: true); - final testStrategy = TestLocationStrategy.fromEntry( + // Disable URL strategy. + expect(() => jsSetUrlStrategy(null), returnsNormally); + // 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', () { + expect(() => jsSetUrlStrategy(123), throwsA(anything)); + 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 setStrategy(testStrategy); - - expect(window.locationStrategy, testStrategy); - // A single listener should've been setup. - expect(testStrategy.listeners, hasLength(1)); - // The initial entry should be there, plus another "flutter" entry. - expect(testStrategy.history, hasLength(2)); - expect(testStrategy.history[0].state, {'origin': true, 'state': 'initial state'}); - expect(testStrategy.history[1].state, {'flutter': true}); - expect(testStrategy.currentEntry, testStrategy.history[1]); - - // Now, let's disable location strategy and make sure things get cleaned up. - expect(() => jsSetLocationStrategy(null), returnsNormally); - // The locationStrategy is teared down asynchronously. - await Future.delayed(Duration.zero); - expect(window.locationStrategy, isNull); - - // The listener is removed asynchronously. - 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]); + await window.debugInitializeHistory(testStrategy, useSingle: true); + + expect(() => jsSetUrlStrategy(null), throwsA(isAssertionError)); }); - test('js interop throws on wrong type', () { - expect(() => jsSetLocationStrategy(123), throwsA(anything)); - expect(() => jsSetLocationStrategy('foo'), throwsA(anything)); - expect(() => jsSetLocationStrategy(false), throwsA(anything)); + 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 jsSetLocationStrategy(dynamic strategy) { +void jsSetUrlStrategy(dynamic strategy) { js_util.callMethod( html.window, '_flutter_web_set_location_strategy',