[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/');
+  });
+}