[web] Support custom url strategies (#59797)
diff --git a/dev/benchmarks/macrobenchmarks/pubspec.yaml b/dev/benchmarks/macrobenchmarks/pubspec.yaml
index 8bfe509..c26ec3b 100644
--- a/dev/benchmarks/macrobenchmarks/pubspec.yaml
+++ b/dev/benchmarks/macrobenchmarks/pubspec.yaml
@@ -43,6 +43,7 @@
http: 0.12.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
http_parser: 3.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
intl: 0.16.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ js: 0.6.3-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
json_rpc_2: 2.2.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
matcher: 0.12.10-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
meta: 1.3.0-nullsafety.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
@@ -105,7 +106,6 @@
html: 0.14.0+3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
http_multi_server: 2.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
io: 0.3.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
- js: 0.6.3-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
logging: 0.11.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
mime: 0.9.7 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
node_interop: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
diff --git a/dev/integration_tests/flutter_gallery/pubspec.yaml b/dev/integration_tests/flutter_gallery/pubspec.yaml
index 09c4133..7079ec0 100644
--- a/dev/integration_tests/flutter_gallery/pubspec.yaml
+++ b/dev/integration_tests/flutter_gallery/pubspec.yaml
@@ -28,6 +28,7 @@
connectivity_macos: 0.1.0+5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
connectivity_platform_interface: 1.0.6 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
device_info_platform_interface: 1.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ js: 0.6.3-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
meta: 1.3.0-nullsafety.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
path: 1.8.0-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
plugin_platform_interface: 1.0.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
@@ -73,7 +74,6 @@
http_multi_server: 2.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
http_parser: 3.1.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
io: 0.3.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
- js: 0.6.3-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
json_rpc_2: 2.2.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
logging: 0.11.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
matcher: 0.12.10-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
diff --git a/packages/flutter_web_plugins/lib/flutter_web_plugins.dart b/packages/flutter_web_plugins/lib/flutter_web_plugins.dart
index cf5b0d1..1bb8281 100644
--- a/packages/flutter_web_plugins/lib/flutter_web_plugins.dart
+++ b/packages/flutter_web_plugins/lib/flutter_web_plugins.dart
@@ -17,5 +17,6 @@
/// describing how the `url_launcher` package was created using [flutter_web_plugins].
library flutter_web_plugins;
+export 'src/navigation/url_strategy.dart';
export 'src/plugin_event_channel.dart';
export 'src/plugin_registry.dart';
diff --git a/packages/flutter_web_plugins/lib/src/navigation/js_url_strategy.dart b/packages/flutter_web_plugins/lib/src/navigation/js_url_strategy.dart
new file mode 100644
index 0000000..9ebaac5
--- /dev/null
+++ b/packages/flutter_web_plugins/lib/src/navigation/js_url_strategy.dart
@@ -0,0 +1,118 @@
+// Copyright 2014 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.8
+
+@JS()
+library js_location_strategy;
+
+import 'dart:async';
+import 'dart:html' as html;
+import 'dart:ui' as ui;
+
+import 'package:js/js.dart';
+import 'package:meta/meta.dart';
+
+import 'url_strategy.dart';
+
+typedef _JsSetUrlStrategy = void Function(JsUrlStrategy);
+
+/// A JavaScript hook to customize the URL strategy of a Flutter app.
+//
+// Keep this in sync with the JS name in the web engine. Find it at:
+// https://github.com/flutter/engine/blob/custom_location_strategy/lib/web_ui/lib/src/engine/navigation/js_url_strategy.dart
+//
+// TODO(mdebbar): Add integration test https://github.com/flutter/flutter/issues/66852
+@JS('_flutter_web_set_location_strategy')
+external _JsSetUrlStrategy get jsSetUrlStrategy;
+
+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<void> Function(int count);
+
+/// Given a Dart implementation of URL strategy, converts it to a JavaScript
+/// URL strategy to 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
+/// 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
+ /// 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<void> go(int count);
+}
diff --git a/packages/flutter_web_plugins/lib/src/navigation/url_strategy.dart b/packages/flutter_web_plugins/lib/src/navigation/url_strategy.dart
new file mode 100644
index 0000000..2470f6b
--- /dev/null
+++ b/packages/flutter_web_plugins/lib/src/navigation/url_strategy.dart
@@ -0,0 +1,320 @@
+// Copyright 2014 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.8
+
+import 'dart:async';
+import 'dart:html' as html;
+import 'dart:ui' as ui;
+
+import 'js_url_strategy.dart';
+import 'utils.dart';
+
+/// Change the strategy to use for handling browser URL.
+///
+/// Setting this to null disables all integration with the browser history.
+void setUrlStrategy(UrlStrategy strategy) {
+ jsSetUrlStrategy(convertToJsUrlStrategy(strategy));
+}
+
+/// 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<void> go(int count);
+}
+
+/// Uses the browser URL's [hash fragments](https://en.wikipedia.org/wiki/Uniform_Resource_Locator#Syntax)
+/// to represent its state.
+///
+/// By default, this class is used as the URL strategy for the app. However,
+/// this class is still useful for apps that want to extend it.
+///
+/// In order to use [HashUrlStrategy] 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<void> 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<void> _waitForPopState() {
+ final Completer<void> completer = Completer<void>();
+ ui.VoidCallback unsubscribe;
+ unsubscribe = addPopStateListener((_) {
+ unsubscribe();
+ completer.complete();
+ });
+ return completer.future;
+ }
+}
+
+/// Uses the browser URL's pathname to represent Flutter's route name.
+///
+/// In order to use [PathUrlStrategy] 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(PathUrlStrategy());
+/// ```
+class PathUrlStrategy extends HashUrlStrategy {
+ /// Creates an instance of [PathUrlStrategy].
+ ///
+ /// The [PlatformLocation] parameter is useful for testing to mock out browser
+ /// interations.
+ PathUrlStrategy([
+ PlatformLocation _platformLocation = const BrowserPlatformLocation(),
+ ]) : _basePath = stripTrailingSlash(extractPathname(checkBaseHref(
+ _platformLocation.getBaseHref(),
+ ))),
+ super(_platformLocation);
+
+ final String _basePath;
+
+ @override
+ String getPath() {
+ final String path = _platformLocation.pathname + _platformLocation.search;
+ if (_basePath.isNotEmpty && path.startsWith(_basePath)) {
+ return ensureLeadingSlash(path.substring(_basePath.length));
+ }
+ return ensureLeadingSlash(path);
+ }
+
+ @override
+ String prepareExternalUrl(String internalUrl) {
+ if (internalUrl.isNotEmpty && !internalUrl.startsWith('/')) {
+ internalUrl = '/$internalUrl';
+ }
+ return '$_basePath$internalUrl';
+ }
+}
+
+/// 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() => getBaseElementHrefFromDom();
+ // String getBaseHref() => html.document.baseUri;
+}
diff --git a/packages/flutter_web_plugins/lib/src/navigation/utils.dart b/packages/flutter_web_plugins/lib/src/navigation/utils.dart
new file mode 100644
index 0000000..ea742aa
--- /dev/null
+++ b/packages/flutter_web_plugins/lib/src/navigation/utils.dart
@@ -0,0 +1,67 @@
+// Copyright 2014 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.8
+
+import 'dart:html';
+
+AnchorElement _urlParsingNode;
+
+/// Extracts the pathname part of a full [url].
+///
+/// Example: for the url `http://example.com/foo`, the extracted pathname will
+/// be `/foo`.
+String extractPathname(String url) {
+ // TODO(mdebbar): Use the `URI` class instead?
+ _urlParsingNode ??= AnchorElement();
+ _urlParsingNode.href = url;
+ final String pathname = _urlParsingNode.pathname;
+ return (pathname.isEmpty || pathname[0] == '/') ? pathname : '/$pathname';
+}
+
+Element _baseElement;
+
+/// Finds the <base> element in the document and returns its `href` attribute.
+///
+/// Returns null if the element isn't found.
+String getBaseElementHrefFromDom() {
+ if (_baseElement == null) {
+ _baseElement = document.querySelector('base');
+ if (_baseElement == null) {
+ return null;
+ }
+ }
+ return _baseElement.getAttribute('href');
+}
+
+/// Checks that [baseHref] is set.
+///
+/// Throws an exception otherwise.
+String checkBaseHref(String baseHref) {
+ if (baseHref == null) {
+ throw Exception('Please add a <base> element to your index.html');
+ }
+ if (!baseHref.endsWith('/')) {
+ throw Exception('The base href has to end with a "/" to work correctly');
+ }
+ return baseHref;
+}
+
+/// Prepends a forward slash to [path] if it doesn't start with one already.
+///
+/// Returns [path] unchanged if it already starts with a forward slash.
+String ensureLeadingSlash(String path) {
+ if (!path.startsWith('/')) {
+ return '/$path';
+ }
+ return path;
+}
+
+/// Removes the trailing forward slash from [path] if any.
+String stripTrailingSlash(String path) {
+ if (path.endsWith('/')) {
+ return path.substring(0, path.length - 1);
+ }
+ return path;
+}
diff --git a/packages/flutter_web_plugins/pubspec.yaml b/packages/flutter_web_plugins/pubspec.yaml
index 5577d28..f1fc02f 100644
--- a/packages/flutter_web_plugins/pubspec.yaml
+++ b/packages/flutter_web_plugins/pubspec.yaml
@@ -10,6 +10,8 @@
flutter:
sdk: flutter
+ js: 0.6.3-nullsafety.1
+
characters: 1.1.0-nullsafety.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
collection: 1.15.0-nullsafety.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
meta: 1.3.0-nullsafety.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
@@ -34,4 +36,4 @@
term_glyph: 1.2.0-nullsafety.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
test_api: 0.2.19-nullsafety.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
-# PUBSPEC CHECKSUM: 417a
+# PUBSPEC CHECKSUM: 2180
diff --git a/packages/flutter_web_plugins/test/navigation/url_strategy_test.dart b/packages/flutter_web_plugins/test/navigation/url_strategy_test.dart
new file mode 100644
index 0000000..156a631
--- /dev/null
+++ b/packages/flutter_web_plugins/test/navigation/url_strategy_test.dart
@@ -0,0 +1,188 @@
+// Copyright 2014 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.8
+
+import 'dart:html';
+
+@TestOn('chrome') // Uses web-only Flutter SDK
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_web_plugins/flutter_web_plugins.dart';
+
+void main() {
+ group('$HashUrlStrategy', () {
+ TestPlatformLocation location;
+
+ setUp(() {
+ location = TestPlatformLocation();
+ });
+
+ tearDown(() {
+ location = null;
+ });
+
+ test('leading slash is optional', () {
+ final HashUrlStrategy strategy = HashUrlStrategy(location);
+
+ location.hash = '#/';
+ expect(strategy.getPath(), '/');
+
+ location.hash = '#/foo';
+ expect(strategy.getPath(), '/foo');
+
+ location.hash = '#foo';
+ expect(strategy.getPath(), 'foo');
+ });
+
+ test('path should not be empty', () {
+ final HashUrlStrategy strategy = HashUrlStrategy(location);
+
+ location.hash = '';
+ expect(strategy.getPath(), '/');
+
+ location.hash = '#';
+ expect(strategy.getPath(), '/');
+ });
+ });
+
+ group('$PathUrlStrategy', () {
+ TestPlatformLocation location;
+
+ setUp(() {
+ location = TestPlatformLocation();
+ });
+
+ tearDown(() {
+ location = null;
+ });
+
+ test('validates base href', () {
+ location.baseHref = '/';
+ expect(
+ () => PathUrlStrategy(location),
+ returnsNormally,
+ );
+
+ location.baseHref = '/foo/';
+ expect(
+ () => PathUrlStrategy(location),
+ returnsNormally,
+ );
+
+ location.baseHref = '';
+ expect(
+ () => PathUrlStrategy(location),
+ throwsException,
+ );
+
+ location.baseHref = 'foo';
+ expect(
+ () => PathUrlStrategy(location),
+ throwsException,
+ );
+
+ location.baseHref = '/foo';
+ expect(
+ () => PathUrlStrategy(location),
+ throwsException,
+ );
+ });
+
+ test('leading slash is always prepended', () {
+ location.baseHref = '/';
+ final PathUrlStrategy strategy = PathUrlStrategy(location);
+
+ location.pathname = '';
+ expect(strategy.getPath(), '/');
+
+ location.pathname = 'foo';
+ expect(strategy.getPath(), '/foo');
+ });
+
+ test('gets path correctly in the presence of basePath', () {
+ location.baseHref = 'https://example.com/foo/';
+ final PathUrlStrategy strategy = PathUrlStrategy(location);
+
+ location.pathname = '/foo/';
+ expect(strategy.getPath(), '/');
+
+ location.pathname = '/foo';
+ expect(strategy.getPath(), '/');
+
+ location.pathname = '/foo/bar';
+ expect(strategy.getPath(), '/bar');
+ });
+
+ test('gets path correctly in the presence of query params', () {
+ location.baseHref = 'https://example.com/foo/';
+ location.pathname = '/foo/bar';
+ final PathUrlStrategy strategy = PathUrlStrategy(location);
+
+
+ location.search = '?q=1';
+ expect(strategy.getPath(), '/bar?q=1');
+
+ location.search = '?q=1&t=r';
+ expect(strategy.getPath(), '/bar?q=1&t=r');
+ });
+
+ test('generates external path correctly in the presence of basePath', () {
+ location.baseHref = 'https://example.com/foo/';
+ final PathUrlStrategy strategy = PathUrlStrategy(location);
+
+ expect(strategy.prepareExternalUrl(''), '/foo');
+ expect(strategy.prepareExternalUrl('/'), '/foo/');
+ expect(strategy.prepareExternalUrl('bar'), '/foo/bar');
+ expect(strategy.prepareExternalUrl('/bar'), '/foo/bar');
+ expect(strategy.prepareExternalUrl('/bar/'), '/foo/bar/');
+ });
+ });
+}
+
+/// A mock implementation of [PlatformLocation] that doesn't access the browser.
+class TestPlatformLocation extends PlatformLocation {
+ @override
+ String pathname = '';
+
+ @override
+ String search = '';
+
+ @override
+ String hash = '';
+
+ @override
+ dynamic state;
+
+ /// Mocks the base href of the document.
+ String baseHref = '';
+
+ @override
+ void addPopStateListener(EventListener fn) {
+ throw UnimplementedError();
+ }
+
+ @override
+ void removePopStateListener(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();
+ }
+
+ @override
+ void go(int count) {
+ throw UnimplementedError();
+ }
+
+ @override
+ String getBaseHref() => baseHref;
+}
diff --git a/packages/flutter_web_plugins/test/navigation/utils_test.dart b/packages/flutter_web_plugins/test/navigation/utils_test.dart
new file mode 100644
index 0000000..419b49f
--- /dev/null
+++ b/packages/flutter_web_plugins/test/navigation/utils_test.dart
@@ -0,0 +1,38 @@
+// Copyright 2014 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.8
+
+@TestOn('browser') // Uses web-only Flutter SDK
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_web_plugins/src/navigation/utils.dart';
+
+void main() {
+ test('checks base href', () {
+ expect(() => checkBaseHref(null), throwsException);
+ expect(() => checkBaseHref('foo'), throwsException);
+ expect(() => checkBaseHref('/foo'), throwsException);
+ expect(() => checkBaseHref('foo/bar'), throwsException);
+ expect(() => checkBaseHref('/foo/bar'), throwsException);
+
+ expect(() => checkBaseHref('/'), returnsNormally);
+ expect(() => checkBaseHref('/foo/'), returnsNormally);
+ expect(() => checkBaseHref('/foo/bar/'), returnsNormally);
+ });
+
+ test('extracts pathname from URL', () {
+ expect(extractPathname('/'), '/');
+ expect(extractPathname('/foo'), '/foo');
+ expect(extractPathname('/foo/'), '/foo/');
+ expect(extractPathname('/foo/bar'), '/foo/bar');
+ expect(extractPathname('/foo/bar/'), '/foo/bar/');
+
+ expect(extractPathname('https://example.com'), '/');
+ expect(extractPathname('https://example.com/'), '/');
+ expect(extractPathname('https://example.com/foo'), '/foo');
+ expect(extractPathname('https://example.com/foo#bar'), '/foo');
+ expect(extractPathname('https://example.com/foo/#bar'), '/foo/');
+ });
+}