From a4e90aa447b6d04e5edf9b6107bd8e24106a2a9e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 02:39:45 -0700
Subject: [PATCH 01/27] Fix docs publishing

---
 .github/workflows/test-docs.yml | 1 -
 requirements/build-docs.txt     | 1 +
 2 files changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml
index 7110bc4..df91de1 100644
--- a/.github/workflows/test-docs.yml
+++ b/.github/workflows/test-docs.yml
@@ -25,7 +25,6 @@ jobs:
           pip install -r requirements/build-docs.txt
           pip install -r requirements/check-types.txt
           pip install -r requirements/check-style.txt
-          pip install -e .
       - name: Check docs build
         run: |
           linkcheckMarkdown docs/ -v -r
diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt
index 0d2bca2..f6561b3 100644
--- a/requirements/build-docs.txt
+++ b/requirements/build-docs.txt
@@ -9,3 +9,4 @@ mkdocs-minify-plugin
 mkdocs-section-index
 mike
 mkdocstrings[python]
+.

From 491027d14c825ffa70b4217a6bc73cb45331b676 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 17:58:38 -0700
Subject: [PATCH 02/27] functional navigate component

---
 src/js/src/index.js              | 41 ++++++++++++++++++++++----------
 src/reactpy_router/__init__.py   |  3 ++-
 src/reactpy_router/components.py | 31 ++++++++++++++++++++++--
 src/reactpy_router/routers.py    |  2 +-
 4 files changed, 61 insertions(+), 16 deletions(-)

diff --git a/src/js/src/index.js b/src/js/src/index.js
index 8ead7eb..819b237 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -12,13 +12,13 @@ export function bind(node) {
   };
 }
 
-export function History({ onHistoryChange }) {
+export function History({ onHistoryChangeCallback }) {
   // Capture browser "history go back" action and tell the server about it
   // Note: Browsers do not allow us to detect "history go forward" actions.
   React.useEffect(() => {
     // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback.
     const listener = () => {
-      onHistoryChange({
+      onHistoryChangeCallback({
         pathname: window.location.pathname,
         search: window.location.search,
       });
@@ -34,20 +34,20 @@ export function History({ onHistoryChange }) {
   // Tell the server about the URL during the initial page load
   // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug.
   // https://github.com/reactive-python/reactpy/pull/1224
-  React.useEffect(() => {
-    onHistoryChange({
-      pathname: window.location.pathname,
-      search: window.location.search,
-    });
-    return () => {};
-  }, []);
+  // React.useEffect(() => {
+  //   onHistoryChange({
+  //     pathname: window.location.pathname,
+  //     search: window.location.search,
+  //   });
+  //   return () => {};
+  // }, []);
   return null;
 }
 
 // FIXME: The Link component is unused due to a ReactPy core rendering bug
 // which causes duplicate rendering (and thus duplicate event listeners).
 // https://github.com/reactive-python/reactpy/pull/1224
-export function Link({ onClick, linkClass }) {
+export function Link({ onClickCallback, linkClass }) {
   // This component is not the actual anchor link.
   // It is an event listener for the link component created by ReactPy.
   React.useEffect(() => {
@@ -55,8 +55,8 @@ export function Link({ onClick, linkClass }) {
     const handleClick = (event) => {
       event.preventDefault();
       let to = event.target.getAttribute("href");
-      window.history.pushState({}, to, new URL(to, window.location));
-      onClick({
+      window.history.pushState(null, "", new URL(to, window.location));
+      onClickCallback({
         pathname: window.location.pathname,
         search: window.location.search,
       });
@@ -78,3 +78,20 @@ export function Link({ onClick, linkClass }) {
   });
   return null;
 }
+
+export function Navigate({ onNavigateCallback, to, replace }) {
+  React.useEffect(() => {
+    if (replace) {
+      window.history.replaceState(null, "", new URL(to, window.location));
+    } else {
+      window.history.pushState(null, "", new URL(to, window.location));
+    }
+    onNavigateCallback({
+      pathname: window.location.pathname,
+      search: window.location.search,
+    });
+    return () => {};
+  }, []);
+
+  return null;
+}
diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py
index fa2781f..9f272c2 100644
--- a/src/reactpy_router/__init__.py
+++ b/src/reactpy_router/__init__.py
@@ -2,7 +2,7 @@
 __version__ = "0.1.1"
 
 
-from .components import link, route
+from .components import link, navigate, route
 from .hooks import use_params, use_search_params
 from .routers import browser_router, create_router
 
@@ -13,4 +13,5 @@
     "browser_router",
     "use_params",
     "use_search_params",
+    "navigate",
 )
diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py
index 6c023d4..39f3654 100644
--- a/src/reactpy_router/components.py
+++ b/src/reactpy_router/components.py
@@ -26,6 +26,12 @@
 )
 """Client-side portion of link handling"""
 
+Navigate = export(
+    module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
+    ("Navigate"),
+)
+"""Client-side portion of the navigate component"""
+
 link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8")
 
 
@@ -93,11 +99,32 @@ def on_click(_event: dict[str, Any]) -> None:
 
     return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string)))
 
-    # def on_click(_event: dict[str, Any]) -> None:
+    # def on_click_callback(_event: dict[str, Any]) -> None:
     #     set_location(Location(**_event))
-    # return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string}))
+    # return html._(html.a(attrs, *children), Link({"onClickCallback": on_click_callback, "linkClass": uuid_string}))
 
 
 def route(path: str, element: Any | None, *routes: Route) -> Route:
     """Create a route with the given path, element, and child routes."""
     return Route(path, element, routes)
+
+
+def navigate(to: str, replace: bool = False) -> Component:
+    """A `navigate` element changes the current location when it is rendered."""
+    return _navigate(to, replace)
+
+
+@component
+def _navigate(to: str, replace: bool = False) -> VdomDict | None:
+    """A `navigate` element changes the current location when it is rendered."""
+    location = use_connection().location
+    set_location = _use_route_state().set_location
+    pathname = to.split("?", 1)[0]
+
+    def on_navigate_callback(_event: dict[str, Any]) -> None:
+        set_location(Location(**_event))
+
+    if location.pathname != pathname:
+        return Navigate({"onNavigateCallback": on_navigate_callback, "to": to, "replace": replace})
+
+    return None
diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
index 25b72c1..4b46151 100644
--- a/src/reactpy_router/routers.py
+++ b/src/reactpy_router/routers.py
@@ -71,7 +71,7 @@ def on_history_change(event: dict[str, Any]) -> None:
                 set_location(new_location)
 
         return ConnectionContext(
-            History({"onHistoryChange": on_history_change}),  # type: ignore[return-value]
+            History({"onHistoryChangeCallback": on_history_change}),  # type: ignore[return-value]
             *route_elements,
             value=Connection(old_conn.scope, location, old_conn.carrier),
         )

From cac297bdc81087046d8f4537afe849cfe8b356e5 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 21:12:22 -0700
Subject: [PATCH 03/27] Better fix for key identity of route components

---
 src/reactpy_router/routers.py | 23 ++++++++++++++++++-----
 1 file changed, 18 insertions(+), 5 deletions(-)

diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
index 4b46151..804cf89 100644
--- a/src/reactpy_router/routers.py
+++ b/src/reactpy_router/routers.py
@@ -4,13 +4,12 @@
 
 from dataclasses import replace
 from logging import getLogger
-from typing import Any, Iterator, Literal, Sequence
+from typing import Any, Iterator, Literal, Sequence, cast
 
 from reactpy import component, use_memo, use_state
 from reactpy.backend.hooks import ConnectionContext, use_connection
 from reactpy.backend.types import Connection, Location
-from reactpy.core.types import VdomDict
-from reactpy.types import ComponentType
+from reactpy.types import ComponentType, VdomDict
 
 from reactpy_router.components import History
 from reactpy_router.hooks import _route_state_context, _RouteState
@@ -86,6 +85,18 @@ def _iter_routes(routes: Sequence[RouteType]) -> Iterator[RouteType]:
         yield parent
 
 
+def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any:
+    """Add a key to the VDOM or component on the current route, if it doesn't already have one."""
+    element, _params = match
+    if hasattr(element, "render") and not element.key:
+        element = cast(ComponentType, element)
+        element.key = key
+    elif isinstance(element, dict) and not element.get("key", None):
+        element = cast(VdomDict, element)
+        element["key"] = key
+    return match
+
+
 def _match_route(
     compiled_routes: Sequence[CompiledRoute],
     location: Location,
@@ -97,12 +108,14 @@ def _match_route(
         match = resolver.resolve(location.pathname)
         if match is not None:
             if select == "first":
-                return [match]
+                return [_add_route_key(match, resolver.key)]
 
             # Matching multiple routes is disabled since `react-router` no longer supports multiple
             # matches via the `Route` component. However, it's kept here to support future changes
             # or third-party routers.
-            matches.append(match)  # pragma: no cover
+            # TODO: The `resolver.key` value has edge cases where it is not unique enough to use as
+            # a key here, unless we begin throwing errors for duplicate routes.
+            matches.append(_add_route_key(match, resolver.key))  # pragma: no cover
 
     if not matches:
         _logger.debug("No matching route found for %s", location.pathname)

From e730eea3698637517c6a8f18a8b9ef2eefbe5a58 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 21:13:19 -0700
Subject: [PATCH 04/27] Add tests for navigate component

---
 docs/src/reference/components.md |  2 +-
 tests/test_router.py             | 57 ++++++++++++++++++++++++++++++--
 2 files changed, 56 insertions(+), 3 deletions(-)

diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md
index f1cc570..9841110 100644
--- a/docs/src/reference/components.md
+++ b/docs/src/reference/components.md
@@ -1,4 +1,4 @@
 ::: reactpy_router
 
     options:
-        members: ["route", "link"]
+        members: ["route", "link", "navigate"]
diff --git a/tests/test_router.py b/tests/test_router.py
index d6e0deb..095a9ee 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -2,10 +2,10 @@
 from typing import Any
 
 from playwright.async_api._generated import Browser, Page
-from reactpy import Ref, component, html, use_location
+from reactpy import Ref, component, html, use_location, use_state
 from reactpy.testing import DisplayFixture
 
-from reactpy_router import browser_router, link, route, use_params, use_search_params
+from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
 CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
@@ -295,3 +295,56 @@ def sample():
     browser_context = browser.contexts[0]
     new_page: Page = await browser_context.wait_for_event("page")
     await new_page.wait_for_selector("#a")
+
+
+async def test_navigate_component(display: DisplayFixture):
+    @component
+    def navigate_btn():
+        nav_url, set_nav_url = use_state("")
+
+        return html.button(
+            {"onClick": lambda _: set_nav_url("/a")},
+            navigate(nav_url) if nav_url else "Click to navigate",
+        )
+
+    @component
+    def sample():
+        return browser_router(
+            route("/", navigate_btn()),
+            route("/a", html.h1({"id": "a"}, "A")),
+        )
+
+    await display.show(sample)
+    _button = await display.page.wait_for_selector("button")
+    await _button.click(delay=CLICK_DELAY)
+    await display.page.wait_for_selector("#a")
+    await display.page.go_back()
+    await display.page.wait_for_selector("button")
+
+
+async def test_navigate_component_replace(display: DisplayFixture):
+    @component
+    def navigate_btn(to: str, replace: bool = False):
+        nav_url, set_nav_url = use_state("")
+
+        return html.button(
+            {"onClick": lambda _: set_nav_url(to), "id": f"nav-{to.replace('/', '')}"},
+            navigate(nav_url, replace) if nav_url else f"Navigate to {to}",
+        )
+
+    @component
+    def sample():
+        return browser_router(
+            route("/", navigate_btn("/a")),
+            route("/a", navigate_btn("/b", replace=True)),
+            route("/b", html.h1({"id": "b"}, "B")),
+        )
+
+    await display.show(sample)
+    _button = await display.page.wait_for_selector("#nav-a")
+    await _button.click(delay=CLICK_DELAY)
+    _button = await display.page.wait_for_selector("#nav-b")
+    await _button.click(delay=CLICK_DELAY)
+    await display.page.wait_for_selector("#b")
+    await display.page.go_back()
+    await display.page.wait_for_selector("#nav-a")

From 831aa206ab3fd1f52f16eee2225cdfdf8345f8f2 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 21:28:35 -0700
Subject: [PATCH 05/27] Add comments to javascript

---
 src/js/src/index.js | 43 +++++++++++++++++++++++++++++++++++++------
 1 file changed, 37 insertions(+), 6 deletions(-)

diff --git a/src/js/src/index.js b/src/js/src/index.js
index 819b237..c59fea3 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -12,9 +12,19 @@ export function bind(node) {
   };
 }
 
+/**
+ * History component that captures browser "history go back" actions and notifies the server.
+ *
+ * @param {Object} props - The properties object.
+ * @param {Function} props.onHistoryChangeCallback - Callback function to notify the server about history changes.
+ * @returns {null} This component does not render any visible output.
+ * @description
+ * This component uses the `popstate` event to detect when the user navigates back in the browser history.
+ * It then calls the `onHistoryChangeCallback` with the current pathname and search parameters.
+ * Note: Browsers do not allow detection of "history go forward" actions.
+ * @see https://github.com/reactive-python/reactpy/pull/1224
+ */
 export function History({ onHistoryChangeCallback }) {
-  // Capture browser "history go back" action and tell the server about it
-  // Note: Browsers do not allow us to detect "history go forward" actions.
   React.useEffect(() => {
     // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback.
     const listener = () => {
@@ -32,8 +42,10 @@ export function History({ onHistoryChangeCallback }) {
   });
 
   // Tell the server about the URL during the initial page load
-  // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug.
+  // FIXME: This code is commented out since it currently runs every time any component
+  // is mounted due to a ReactPy core rendering bug.
   // https://github.com/reactive-python/reactpy/pull/1224
+
   // React.useEffect(() => {
   //   onHistoryChange({
   //     pathname: window.location.pathname,
@@ -44,10 +56,20 @@ export function History({ onHistoryChangeCallback }) {
   return null;
 }
 
-// FIXME: The Link component is unused due to a ReactPy core rendering bug
-// which causes duplicate rendering (and thus duplicate event listeners).
-// https://github.com/reactive-python/reactpy/pull/1224
+
+/**
+ * Link component that captures clicks on anchor links and notifies the server.
+ *
+ * @param {Object} props - The properties object.
+ * @param {Function} props.onClickCallback - Callback function to notify the server about link clicks.
+ * @param {string} props.linkClass - The class name of the anchor link.
+ * @returns {null} This component does not render any visible output.
+ */
 export function Link({ onClickCallback, linkClass }) {
+  // FIXME: This component is currently unused due to a ReactPy core rendering bug
+  // which causes duplicate rendering (and thus duplicate event listeners).
+  // https://github.com/reactive-python/reactpy/pull/1224
+
   // This component is not the actual anchor link.
   // It is an event listener for the link component created by ReactPy.
   React.useEffect(() => {
@@ -79,6 +101,15 @@ export function Link({ onClickCallback, linkClass }) {
   return null;
 }
 
+/**
+ * Client-side portion of the navigate component, that allows the server to command the client to change URLs.
+ *
+ * @param {Object} props - The properties object.
+ * @param {Function} props.onNavigateCallback - Callback function that transmits data to the server.
+ * @param {string} props.to - The target URL to navigate to.
+ * @param {boolean} props.replace - If true, replaces the current history entry instead of adding a new one.
+ * @returns {null} This component does not render anything.
+ */
 export function Navigate({ onNavigateCallback, to, replace }) {
   React.useEffect(() => {
     if (replace) {

From c126be909be7fa8b186ca1bac797f94dc577e0e9 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 21:42:44 -0700
Subject: [PATCH 06/27] Temporary fix for `History`'s first load behavior

---
 src/js/src/index.js              | 22 ++++++++++++++++++++++
 src/reactpy_router/components.py |  5 +++++
 src/reactpy_router/routers.py    | 10 +++++++++-
 3 files changed, 36 insertions(+), 1 deletion(-)

diff --git a/src/js/src/index.js b/src/js/src/index.js
index c59fea3..40e25e0 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -56,6 +56,28 @@ export function History({ onHistoryChangeCallback }) {
   return null;
 }
 
+/**
+ * FirstLoad component that captures the URL during the initial page load and notifies the server.
+ *
+ * @param {Object} props - The properties object.
+ * @param {Function} props.onFirstLoadCallback - Callback function to notify the server about the first load.
+ * @returns {null} This component does not render any visible output.
+ * @description
+ * This component sends the current URL to the server during the initial page load.
+ * @see https://github.com/reactive-python/reactpy/pull/1224
+ */
+export function FirstLoad({ onFirstLoadCallback }) {
+  // FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug
+  // is fixed. Ideally all this logic would be handled by the `History` component.
+  React.useEffect(() => {
+    onFirstLoadCallback({
+      pathname: window.location.pathname,
+      search: window.location.search,
+    });
+    return () => {};
+  }, []);
+  return null;
+}
 
 /**
  * Link component that captures clicks on anchor links and notifies the server.
diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py
index 39f3654..a417002 100644
--- a/src/reactpy_router/components.py
+++ b/src/reactpy_router/components.py
@@ -32,6 +32,11 @@
 )
 """Client-side portion of the navigate component"""
 
+FirstLoad = export(
+    module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"),
+    ("FirstLoad"),
+)
+
 link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8")
 
 
diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
index 804cf89..83f5f1b 100644
--- a/src/reactpy_router/routers.py
+++ b/src/reactpy_router/routers.py
@@ -11,7 +11,7 @@
 from reactpy.backend.types import Connection, Location
 from reactpy.types import ComponentType, VdomDict
 
-from reactpy_router.components import History
+from reactpy_router.components import FirstLoad, History
 from reactpy_router.hooks import _route_state_context, _RouteState
 from reactpy_router.resolvers import StarletteResolver
 from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType
@@ -46,6 +46,7 @@ def router(
 
     old_conn = use_connection()
     location, set_location = use_state(old_conn.location)
+    first_load, set_first_load = use_state(True)
 
     resolvers = use_memo(
         lambda: tuple(map(resolver, _iter_routes(routes))),
@@ -69,8 +70,15 @@ def on_history_change(event: dict[str, Any]) -> None:
             if location != new_location:
                 set_location(new_location)
 
+        def on_first_load(event: dict[str, Any]) -> None:
+            """Callback function used within the JavaScript `FirstLoad` component."""
+            if first_load:
+                set_first_load(False)
+            on_history_change(event)
+
         return ConnectionContext(
             History({"onHistoryChangeCallback": on_history_change}),  # type: ignore[return-value]
+            FirstLoad({"onFirstLoadCallback": on_first_load}) if first_load else "",
             *route_elements,
             value=Connection(old_conn.scope, location, old_conn.carrier),
         )

From eee3a873aa307c4782944cd493d5e30d8547197d Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 21:42:53 -0700
Subject: [PATCH 07/27] Longer test delays

---
 tests/test_router.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index 095a9ee..a8030e0 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -8,7 +8,7 @@
 from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
-CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+CLICK_DELAY = 400 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 
 
 async def test_simple_router(display: DisplayFixture):

From b50afd494eb638d7c760e564905f152620c004dc Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 22:30:25 -0700
Subject: [PATCH 08/27] Fix `test_router` failures

---
 src/reactpy_router/routers.py     |  2 +-
 src/reactpy_router/static/link.js |  7 ++++++-
 tests/test_router.py              | 30 ++++++++----------------------
 3 files changed, 15 insertions(+), 24 deletions(-)

diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
index 83f5f1b..cf35e5b 100644
--- a/src/reactpy_router/routers.py
+++ b/src/reactpy_router/routers.py
@@ -74,7 +74,7 @@ def on_first_load(event: dict[str, Any]) -> None:
             """Callback function used within the JavaScript `FirstLoad` component."""
             if first_load:
                 set_first_load(False)
-            on_history_change(event)
+                on_history_change(event)
 
         return ConnectionContext(
             History({"onHistoryChangeCallback": on_history_change}),  # type: ignore[return-value]
diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js
index b574201..0d588af 100644
--- a/src/reactpy_router/static/link.js
+++ b/src/reactpy_router/static/link.js
@@ -5,7 +5,12 @@ document.querySelector(".UUID").addEventListener(
     if (!event.ctrlKey) {
       event.preventDefault();
       let to = event.target.getAttribute("href");
-      window.history.pushState({}, to, new URL(to, window.location));
+      let new_url = new URL(to, window.location);
+
+      // Deduplication needed due to ReactPy rendering bug
+      if (new_url.href !== window.location.href) {
+        window.history.pushState({}, to, new URL(to, window.location));
+      }
     }
   },
   { once: true },
diff --git a/tests/test_router.py b/tests/test_router.py
index a8030e0..98dc5c1 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -8,7 +8,7 @@
 from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
-CLICK_DELAY = 400 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 
 
 async def test_simple_router(display: DisplayFixture):
@@ -209,32 +209,18 @@ def sample():
 
     await display.show(sample)
 
-    for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]:
+    selectors = ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]
+
+    for link_selector in selectors:
         _link = await display.page.wait_for_selector(link_selector)
         await _link.click(delay=CLICK_DELAY)
 
     await display.page.wait_for_selector("#default")
 
-    await display.page.go_back()
-    await display.page.wait_for_selector("#f")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#e")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#d")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#c")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#b")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#a")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#root")
+    selectors.reverse()
+    for link_selector in selectors:
+        await display.page.go_back()
+        await display.page.wait_for_selector(link_selector)
 
 
 async def test_link_with_query_string(display: DisplayFixture):

From d0ac25b693c92f6e4b5ec31feab506a3de0e7c32 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 22:37:42 -0700
Subject: [PATCH 09/27] Fix coverage

---
 tests/test_router.py | 29 ++++++++++++++++++++++++++++-
 1 file changed, 28 insertions(+), 1 deletion(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index 98dc5c1..3443652 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -8,7 +8,7 @@
 from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
-CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+CLICK_DELAY = 400 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 
 
 async def test_simple_router(display: DisplayFixture):
@@ -334,3 +334,30 @@ def sample():
     await display.page.wait_for_selector("#b")
     await display.page.go_back()
     await display.page.wait_for_selector("#nav-a")
+
+
+async def test_navigate_component_to_current_url(display: DisplayFixture):
+    @component
+    def navigate_btn(to: str, html_id: str, replace: bool = False):
+        nav_url, set_nav_url = use_state("")
+
+        return html.button(
+            {"onClick": lambda _: set_nav_url(to), "id": html_id},
+            navigate(nav_url, replace) if nav_url else f"Navigate to {to}",
+        )
+
+    @component
+    def sample():
+        return browser_router(
+            route("/", navigate_btn("/a", "root-a")),
+            route("/a", navigate_btn("/a", "nav-a")),
+        )
+
+    await display.show(sample)
+    _button = await display.page.wait_for_selector("#root-a")
+    await _button.click(delay=CLICK_DELAY)
+    _button = await display.page.wait_for_selector("#nav-a")
+    await _button.click(delay=CLICK_DELAY)
+    await display.page.wait_for_selector("#nav-a")
+    await display.page.go_back()
+    await display.page.wait_for_selector("#root-a")

From 7ff74277802d9fbcfd05cb3cbc2e162c14db65f6 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 22:40:46 -0700
Subject: [PATCH 10/27] Increase click delay again

---
 tests/test_router.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index 3443652..f48287b 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -8,7 +8,7 @@
 from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
-CLICK_DELAY = 400 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+CLICK_DELAY = 500 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 
 
 async def test_simple_router(display: DisplayFixture):

From 9bf48f4ff9394a3879fd263010fe0d70bc38e26b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 23:17:47 -0700
Subject: [PATCH 11/27] Move first load component to the bottom

---
 src/js/src/index.js | 46 ++++++++++++++++++++++-----------------------
 1 file changed, 23 insertions(+), 23 deletions(-)

diff --git a/src/js/src/index.js b/src/js/src/index.js
index 40e25e0..692a8e3 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -56,29 +56,6 @@ export function History({ onHistoryChangeCallback }) {
   return null;
 }
 
-/**
- * FirstLoad component that captures the URL during the initial page load and notifies the server.
- *
- * @param {Object} props - The properties object.
- * @param {Function} props.onFirstLoadCallback - Callback function to notify the server about the first load.
- * @returns {null} This component does not render any visible output.
- * @description
- * This component sends the current URL to the server during the initial page load.
- * @see https://github.com/reactive-python/reactpy/pull/1224
- */
-export function FirstLoad({ onFirstLoadCallback }) {
-  // FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug
-  // is fixed. Ideally all this logic would be handled by the `History` component.
-  React.useEffect(() => {
-    onFirstLoadCallback({
-      pathname: window.location.pathname,
-      search: window.location.search,
-    });
-    return () => {};
-  }, []);
-  return null;
-}
-
 /**
  * Link component that captures clicks on anchor links and notifies the server.
  *
@@ -148,3 +125,26 @@ export function Navigate({ onNavigateCallback, to, replace }) {
 
   return null;
 }
+
+/**
+ * FirstLoad component that captures the URL during the initial page load and notifies the server.
+ *
+ * @param {Object} props - The properties object.
+ * @param {Function} props.onFirstLoadCallback - Callback function to notify the server about the first load.
+ * @returns {null} This component does not render any visible output.
+ * @description
+ * This component sends the current URL to the server during the initial page load.
+ * @see https://github.com/reactive-python/reactpy/pull/1224
+ */
+export function FirstLoad({ onFirstLoadCallback }) {
+  // FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug
+  // is fixed. Ideally all this logic would be handled by the `History` component.
+  React.useEffect(() => {
+    onFirstLoadCallback({
+      pathname: window.location.pathname,
+      search: window.location.search,
+    });
+    return () => {};
+  }, []);
+  return null;
+}

From 66bef6a6a87a2923f980652f17dc3df4e211dc42 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 23:21:57 -0700
Subject: [PATCH 12/27] Run coverage on python 3.11

---
 .github/workflows/test-src.yaml | 70 ++++++++++++++++-----------------
 1 file changed, 35 insertions(+), 35 deletions(-)

diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml
index df93152..dbaa759 100644
--- a/.github/workflows/test-src.yaml
+++ b/.github/workflows/test-src.yaml
@@ -1,40 +1,40 @@
 name: Test
 
 on:
-    push:
-        branches:
-            - main
-    pull_request:
-        branches:
-            - main
-    schedule:
-        - cron: "0 0 * * *"
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+  schedule:
+    - cron: "0 0 * * *"
 
 jobs:
-    source:
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                python-version: ["3.9", "3.10", "3.11"]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Python ${{ matrix.python-version }}
-              uses: actions/setup-python@v5
-              with:
-                  python-version: ${{ matrix.python-version }}
-            - name: Install Python Dependencies
-              run: pip install -r requirements/test-run.txt
-            - name: Run Tests
-              run: nox -t test
-    coverage:
-        runs-on: ubuntu-latest
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Latest Python
-              uses: actions/setup-python@v5
-              with:
-                  python-version: "3.10"
-            - name: Install Python Dependencies
-              run: pip install -r requirements/test-run.txt
-            - name: Run Tests
-              run: nox -t test -- --coverage
+  source:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.9", "3.10", "3.11"]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Use Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install Python Dependencies
+        run: pip install -r requirements/test-run.txt
+      - name: Run Tests
+        run: nox -t test
+  coverage:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Use Latest Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
+      - name: Install Python Dependencies
+        run: pip install -r requirements/test-run.txt
+      - name: Run Tests
+        run: nox -t test -- --coverage

From 53cdb9e6e181f55941ca0b5e2350ef1b01091968 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 14 Oct 2024 23:22:06 -0700
Subject: [PATCH 13/27] More clear comment on _match_route

---
 src/reactpy_router/routers.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
index cf35e5b..1bea240 100644
--- a/src/reactpy_router/routers.py
+++ b/src/reactpy_router/routers.py
@@ -122,7 +122,7 @@ def _match_route(
             # matches via the `Route` component. However, it's kept here to support future changes
             # or third-party routers.
             # TODO: The `resolver.key` value has edge cases where it is not unique enough to use as
-            # a key here, unless we begin throwing errors for duplicate routes.
+            # a key here. We can potentially fix this by throwing errors for duplicate identical routes.
             matches.append(_add_route_key(match, resolver.key))  # pragma: no cover
 
     if not matches:

From 6e64956a63e143a0d7c7cc5a680eb444e8800cfe Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 01:22:58 -0700
Subject: [PATCH 14/27] Update changelog

---
 CHANGELOG.md | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f652ffa..c2df3df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,14 +42,15 @@ Using the following categories, list your changes in this order:
 -   Rename `CONVERSION_TYPES` to `CONVERTERS`.
 -   Change "Match Any" syntax from a star `*` to `{name:any}`.
 -   Rewrite `reactpy_router.link` to be a server-side component.
--   Simplified top-level exports within `reactpy_router`.
+-   Simplified top-level exports that are available within `reactpy_router.*`.
 
 ### Added
 
--   New error for ReactPy router elements being used outside router context.
--   Configurable/inheritable `Resolver` base class.
 -   Add debug log message for when there are no router matches.
 -   Add slug as a supported type.
+-   Add `reactpy_router.navigate` component that will force the client to navigate to a new URL (when rendered).
+-   New error for ReactPy router elements being used outside router context.
+-   Configurable/inheritable `Resolver` base class.
 
 ### Fixed
 

From c8e06fb1e03e46ad901b1f1d9c778d5b754aebcd Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 16:29:37 -0700
Subject: [PATCH 15/27] Fix tests on windows

---
 requirements/test-env.txt |  2 +-
 tests/conftest.py         | 16 ++++++----------
 tests/test_router.py      |  2 ++
 3 files changed, 9 insertions(+), 11 deletions(-)

diff --git a/requirements/test-env.txt b/requirements/test-env.txt
index 4ddd635..4b78ca5 100644
--- a/requirements/test-env.txt
+++ b/requirements/test-env.txt
@@ -1,6 +1,6 @@
 twine
 pytest
-pytest-asyncio
+anyio
 pytest-cov
 reactpy[testing,starlette]
 nodejs-bin==18.4.0a4
diff --git a/tests/conftest.py b/tests/conftest.py
index 18e3646..7d6f0ed 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,4 @@
-import asyncio
 import os
-import sys
 
 import pytest
 from playwright.async_api import async_playwright
@@ -18,27 +16,25 @@ def pytest_addoption(parser) -> None:
     )
 
 
-@pytest.fixture
+@pytest.fixture(scope="session")
 async def display(backend, browser):
     async with DisplayFixture(backend, browser) as display_fixture:
         display_fixture.page.set_default_timeout(10000)
         yield display_fixture
 
 
-@pytest.fixture
+@pytest.fixture(scope="session")
 async def backend():
     async with BackendFixture() as backend_fixture:
         yield backend_fixture
 
 
-@pytest.fixture
+@pytest.fixture(scope="session")
 async def browser(pytestconfig):
     async with async_playwright() as pw:
         yield await pw.chromium.launch(headless=True if GITHUB_ACTIONS else pytestconfig.getoption("headless"))
 
 
-@pytest.fixture
-def event_loop_policy(request):
-    if sys.platform == "win32":
-        return asyncio.WindowsProactorEventLoopPolicy()
-    return asyncio.get_event_loop_policy()
+@pytest.fixture(scope="session")
+def anyio_backend():
+    return "asyncio"
diff --git a/tests/test_router.py b/tests/test_router.py
index f48287b..06405d3 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -1,6 +1,7 @@
 import os
 from typing import Any
 
+import pytest
 from playwright.async_api._generated import Browser, Page
 from reactpy import Ref, component, html, use_location, use_state
 from reactpy.testing import DisplayFixture
@@ -9,6 +10,7 @@
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
 CLICK_DELAY = 500 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+pytestmark = pytest.mark.anyio
 
 
 async def test_simple_router(display: DisplayFixture):

From fb69cefce2dc01f5c4e53e484e6760f68efe5fcc Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 16:29:49 -0700
Subject: [PATCH 16/27] simplify test_browser_popstate

---
 tests/test_router.py | 19 +++++++------------
 1 file changed, 7 insertions(+), 12 deletions(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index 06405d3..4a212e5 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -176,23 +176,18 @@ def sample():
 
     await display.show(sample)
 
-    for link_selector in ["#root", "#a", "#b", "#c"]:
+    link_selectors = ["#root", "#a", "#b", "#c"]
+
+    for link_selector in link_selectors:
         _link = await display.page.wait_for_selector(link_selector)
         await _link.click(delay=CLICK_DELAY)
 
     await display.page.wait_for_selector("#default")
 
-    await display.page.go_back()
-    await display.page.wait_for_selector("#c")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#b")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#a")
-
-    await display.page.go_back()
-    await display.page.wait_for_selector("#root")
+    link_selectors.reverse()
+    for link_selector in link_selectors:
+        await display.page.go_back()
+        await display.page.wait_for_selector(link_selector)
 
 
 async def test_relative_links(display: DisplayFixture):

From 887564338d4ccb72fb498ce83fbfbb94ac46a3aa Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 18:08:41 -0700
Subject: [PATCH 17/27] Attempt fix for flakey tests: add sleep before each
 `go_back` action

---
 tests/test_router.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/tests/test_router.py b/tests/test_router.py
index 4a212e5..a6a27a8 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -1,3 +1,4 @@
+import asyncio
 import os
 from typing import Any
 
@@ -186,6 +187,7 @@ def sample():
 
     link_selectors.reverse()
     for link_selector in link_selectors:
+        await asyncio.sleep(CLICK_DELAY / 1000)
         await display.page.go_back()
         await display.page.wait_for_selector(link_selector)
 
@@ -216,6 +218,7 @@ def sample():
 
     selectors.reverse()
     for link_selector in selectors:
+        await asyncio.sleep(CLICK_DELAY / 1000)
         await display.page.go_back()
         await display.page.wait_for_selector(link_selector)
 
@@ -301,6 +304,7 @@ def sample():
     _button = await display.page.wait_for_selector("button")
     await _button.click(delay=CLICK_DELAY)
     await display.page.wait_for_selector("#a")
+    await asyncio.sleep(CLICK_DELAY / 1000)
     await display.page.go_back()
     await display.page.wait_for_selector("button")
 
@@ -329,6 +333,7 @@ def sample():
     _button = await display.page.wait_for_selector("#nav-b")
     await _button.click(delay=CLICK_DELAY)
     await display.page.wait_for_selector("#b")
+    await asyncio.sleep(CLICK_DELAY / 1000)
     await display.page.go_back()
     await display.page.wait_for_selector("#nav-a")
 
@@ -356,5 +361,6 @@ def sample():
     _button = await display.page.wait_for_selector("#nav-a")
     await _button.click(delay=CLICK_DELAY)
     await display.page.wait_for_selector("#nav-a")
+    await asyncio.sleep(CLICK_DELAY / 1000)
     await display.page.go_back()
     await display.page.wait_for_selector("#root-a")

From 698eed904c14573f4ed0159a402fd3401fdb4978 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 18:15:14 -0700
Subject: [PATCH 18/27] Attempt fix for flakey tests: Check if new page already
 exists before waiting for page event.

---
 tests/test_router.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index a6a27a8..2c5ec10 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -10,7 +10,7 @@
 from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
-CLICK_DELAY = 500 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 pytestmark = pytest.mark.anyio
 
 
@@ -279,7 +279,10 @@ def sample():
     _link = await display.page.wait_for_selector("#root")
     await _link.click(delay=CLICK_DELAY, modifiers=["Control"])
     browser_context = browser.contexts[0]
-    new_page: Page = await browser_context.wait_for_event("page")
+    if len(browser_context.pages) == 1:
+        new_page: Page = await browser_context.wait_for_event("page")
+    else:
+        new_page: Page = browser_context.pages[-1]  # type: ignore[no-redef]
     await new_page.wait_for_selector("#a")
 
 

From e1c442a85f0a68377005d6ed92451e56b0703c8e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 23:19:12 -0700
Subject: [PATCH 19/27] more comments

---
 src/js/src/index.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/js/src/index.js b/src/js/src/index.js
index 692a8e3..4e7f02f 100644
--- a/src/js/src/index.js
+++ b/src/js/src/index.js
@@ -43,7 +43,7 @@ export function History({ onHistoryChangeCallback }) {
 
   // Tell the server about the URL during the initial page load
   // FIXME: This code is commented out since it currently runs every time any component
-  // is mounted due to a ReactPy core rendering bug.
+  // is mounted due to a ReactPy core rendering bug. `FirstLoad` component is used instead.
   // https://github.com/reactive-python/reactpy/pull/1224
 
   // React.useEffect(() => {

From 7c169b0bf082b3f34b336f9fc3a1c8be7aaed159 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 23:19:21 -0700
Subject: [PATCH 20/27] Add python 3.12 tests

---
 .github/workflows/test-src.yaml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml
index dbaa759..687741b 100644
--- a/.github/workflows/test-src.yaml
+++ b/.github/workflows/test-src.yaml
@@ -15,7 +15,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.9", "3.10", "3.11"]
+        python-version: ["3.9", "3.10", "3.11", "3.12"]
     steps:
       - uses: actions/checkout@v4
       - name: Use Python ${{ matrix.python-version }}
@@ -33,7 +33,7 @@ jobs:
       - name: Use Latest Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.11"
+          python-version: "3.x"
       - name: Install Python Dependencies
         run: pip install -r requirements/test-run.txt
       - name: Run Tests

From a6c378f040cd7b222cdd9a95c0d61b44914fb71f Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 23:19:33 -0700
Subject: [PATCH 21/27] Attempt decreasing GH delay to 250ms

---
 tests/test_router.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index 2c5ec10..054c51c 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -10,7 +10,7 @@
 from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params
 
 GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true"
-CLICK_DELAY = 350 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
+CLICK_DELAY = 250 if GITHUB_ACTIONS else 25  # Delay in miliseconds.
 pytestmark = pytest.mark.anyio
 
 

From a8a7d2748e0cf24c419cc08328e1cc040339887c Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 23:27:45 -0700
Subject: [PATCH 22/27] Standardize history.pushstate args

---
 src/reactpy_router/static/link.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js
index 0d588af..9f78cc5 100644
--- a/src/reactpy_router/static/link.js
+++ b/src/reactpy_router/static/link.js
@@ -9,7 +9,7 @@ document.querySelector(".UUID").addEventListener(
 
       // Deduplication needed due to ReactPy rendering bug
       if (new_url.href !== window.location.href) {
-        window.history.pushState({}, to, new URL(to, window.location));
+        window.history.pushState(null, "", new URL(to, window.location));
       }
     }
   },

From 364e186419a46132c7aa9e5283e54543e3b88a88 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Oct 2024 23:27:52 -0700
Subject: [PATCH 23/27] Remove unused pytest arg

---
 pyproject.toml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index d6a0110..09826fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,4 +17,3 @@ line-length = 120
 
 [tool.pytest.ini_options]
 testpaths = "tests"
-asyncio_mode = "auto"

From 0084b569cc5cf82a7cd75368504bbd753a845370 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Oct 2024 01:16:09 -0700
Subject: [PATCH 24/27] minor test tweaks

---
 tests/test_router.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tests/test_router.py b/tests/test_router.py
index 054c51c..1a4d95c 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -343,12 +343,12 @@ def sample():
 
 async def test_navigate_component_to_current_url(display: DisplayFixture):
     @component
-    def navigate_btn(to: str, html_id: str, replace: bool = False):
+    def navigate_btn(to: str, html_id: str):
         nav_url, set_nav_url = use_state("")
 
         return html.button(
             {"onClick": lambda _: set_nav_url(to), "id": html_id},
-            navigate(nav_url, replace) if nav_url else f"Navigate to {to}",
+            navigate(nav_url) if nav_url else f"Navigate to {to}",
         )
 
     @component
@@ -363,6 +363,7 @@ def sample():
     await _button.click(delay=CLICK_DELAY)
     _button = await display.page.wait_for_selector("#nav-a")
     await _button.click(delay=CLICK_DELAY)
+    await asyncio.sleep(CLICK_DELAY / 1000)
     await display.page.wait_for_selector("#nav-a")
     await asyncio.sleep(CLICK_DELAY / 1000)
     await display.page.go_back()

From 1408d0912e2fbbf63fb438e6d7e1980beecdfdac Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Oct 2024 01:16:27 -0700
Subject: [PATCH 25/27] add changelog for fixing win tests

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2df3df..bf4e08c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,6 +59,7 @@ Using the following categories, list your changes in this order:
 -   Fix bug where `link` elements could not have `@component` type children.
 -   Fix bug where the ReactPy would not detect the current URL after a reconnection.
 -   Fix bug where `ctrl` + `click` on a `link` element would not open in a new tab.
+-   Fix test suite on Windows machines.
 
 ## [0.1.1] - 2023-12-13
 

From 109d87b5a2fcc19e49a33203a056e2fe3c4db1ce Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Oct 2024 00:12:03 -0700
Subject: [PATCH 26/27] more robust docs

---
 docs/mkdocs.yml                              | 17 +++-
 docs/src/dictionary.txt                      |  1 +
 docs/src/reference/{router.md => routers.md} |  0
 docs/src/reference/types.md                  |  4 +
 requirements/build-docs.txt                  |  1 +
 src/reactpy_router/components.py             | 39 ++++++--
 src/reactpy_router/hooks.py                  | 33 +++----
 src/reactpy_router/routers.py                | 25 ++++--
 src/reactpy_router/types.py                  | 95 ++++++++++++++++----
 9 files changed, 169 insertions(+), 46 deletions(-)
 rename docs/src/reference/{router.md => routers.md} (100%)

diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 5173834..ebf8b0e 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -8,7 +8,7 @@ nav:
           - Hooks: learn/hooks.md
           - Creating a Custom Router 🚧: learn/custom-router.md
   - Reference:
-      - Router Components: reference/router.md
+      - Routers: reference/routers.md
       - Components: reference/components.md
       - Hooks: reference/hooks.md
       - Types: reference/types.md
@@ -96,8 +96,21 @@ plugins:
             - https://reactpy.dev/docs/objects.inv
             - https://installer.readthedocs.io/en/stable/objects.inv
           options:
-            show_bases: false
+            signature_crossrefs: true
+            scoped_crossrefs: true
+            relative_crossrefs: true
+            modernize_annotations: true
+            unwrap_annotated: true
+            find_stubs_package: true
             show_root_members_full_path: true
+            show_bases: false
+            show_source: false
+            show_root_toc_entry: false
+            show_labels: false
+            show_symbol_type_toc: true
+            show_symbol_type_heading: true
+            show_object_full_path: true
+            heading_level: 3
 extra:
   generator: false
   version:
diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt
index 6eb9552..64ed74d 100644
--- a/docs/src/dictionary.txt
+++ b/docs/src/dictionary.txt
@@ -37,3 +37,4 @@ misconfiguration
 misconfigurations
 backhaul
 sublicense
+contravariant
diff --git a/docs/src/reference/router.md b/docs/src/reference/routers.md
similarity index 100%
rename from docs/src/reference/router.md
rename to docs/src/reference/routers.md
diff --git a/docs/src/reference/types.md b/docs/src/reference/types.md
index 204bee7..3898ae8 100644
--- a/docs/src/reference/types.md
+++ b/docs/src/reference/types.md
@@ -1 +1,5 @@
 ::: reactpy_router.types
+
+    options:
+        summary: true
+        docstring_section_style: "list"
diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt
index f6561b3..57805cb 100644
--- a/requirements/build-docs.txt
+++ b/requirements/build-docs.txt
@@ -9,4 +9,5 @@ mkdocs-minify-plugin
 mkdocs-section-index
 mike
 mkdocstrings[python]
+black  # for mkdocstrings automatic code formatting
 .
diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py
index a417002..657c558 100644
--- a/src/reactpy_router/components.py
+++ b/src/reactpy_router/components.py
@@ -41,13 +41,21 @@
 
 
 def link(attributes: dict[str, Any], *children: Any) -> Component:
-    """Create a link with the given attributes and children."""
+    """
+    Create a link with the given attributes and children.
+
+    Args:
+        attributes: A dictionary of attributes for the link.
+        *children: Child elements to be included within the link.
+
+    Returns:
+        A link component with the specified attributes and children.
+    """
     return _link(attributes, *children)
 
 
 @component
 def _link(attributes: dict[str, Any], *children: Any) -> VdomDict:
-    """A component that renders a link to the given path."""
     attributes = attributes.copy()
     uuid_string = f"link-{uuid4().hex}"
     class_name = f"{uuid_string}"
@@ -110,18 +118,39 @@ def on_click(_event: dict[str, Any]) -> None:
 
 
 def route(path: str, element: Any | None, *routes: Route) -> Route:
-    """Create a route with the given path, element, and child routes."""
+    """
+    Create a route with the given path, element, and child routes.
+
+    Args:
+        path: The path for the route.
+        element: The element to render for this route. Can be None.
+        routes: Additional child routes.
+
+    Returns:
+        The created route object.
+    """
     return Route(path, element, routes)
 
 
 def navigate(to: str, replace: bool = False) -> Component:
-    """A `navigate` element changes the current location when it is rendered."""
+    """
+    Navigate to a specified URL.
+
+    This function changes the browser's current URL when it is rendered.
+
+    Args:
+        to: The target URL to navigate to.
+        replace: If True, the current history entry will be replaced \
+            with the new URL. Defaults to False.
+
+    Returns:
+        The component responsible for navigation.
+    """
     return _navigate(to, replace)
 
 
 @component
 def _navigate(to: str, replace: bool = False) -> VdomDict | None:
-    """A `navigate` element changes the current location when it is rendered."""
     location = use_connection().location
     set_location = _use_route_state().set_location
     pathname = to.split("?", 1)[0]
diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py
index 3831acf..add8953 100644
--- a/src/reactpy_router/hooks.py
+++ b/src/reactpy_router/hooks.py
@@ -1,21 +1,17 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
-from typing import Any, Callable
+from typing import Any
 from urllib.parse import parse_qs
 
 from reactpy import create_context, use_context, use_location
-from reactpy.backend.types import Location
 from reactpy.types import Context
 
+from reactpy_router.types import RouteState
 
-@dataclass
-class _RouteState:
-    set_location: Callable[[Location], None]
-    params: dict[str, Any]
+_route_state_context: Context[RouteState | None] = create_context(None)
 
 
-def _use_route_state() -> _RouteState:
+def _use_route_state() -> RouteState:
     route_state = use_context(_route_state_context)
     if route_state is None:  # pragma: no cover
         raise RuntimeError(
@@ -26,16 +22,17 @@ def _use_route_state() -> _RouteState:
     return route_state
 
 
-_route_state_context: Context[_RouteState | None] = create_context(None)
-
-
 def use_params() -> dict[str, Any]:
-    """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \
+    """This hook returns an object of key/value pairs of the dynamic parameters \
     from the current URL that were matched by the `Route`. Child routes inherit all parameters \
     from their parent routes.
 
     For example, if you have a `URL_PARAM` defined in the route `/example/<URL_PARAM>/`,
-    this hook will return the URL_PARAM value that was matched."""
+    this hook will return the `URL_PARAM` value that was matched.
+
+    Returns:
+        A dictionary of the current URL's parameters.
+    """
 
     # TODO: Check if this returns all parent params
     return _use_route_state().params
@@ -49,10 +46,14 @@ def use_search_params(
     separator: str = "&",
 ) -> dict[str, list[str]]:
     """
-    The `use_search_params` hook is used to read the query string in the URL \
-    for the current location.
+    This hook is used to read the query string in the URL for the current location.
+
+    See [`urllib.parse.parse_qs`](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.parse_qs) \
+        for info on this hook's parameters.
 
-    See `urllib.parse.parse_qs` for info on this hook's parameters."""
+    Returns:
+        A dictionary of the current URL's query string parameters.
+    """
     location = use_location()
     query_string = location.search[1:] if len(location.search) > 1 else ""
 
diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py
index 1bea240..a815f0d 100644
--- a/src/reactpy_router/routers.py
+++ b/src/reactpy_router/routers.py
@@ -9,10 +9,11 @@
 from reactpy import component, use_memo, use_state
 from reactpy.backend.hooks import ConnectionContext, use_connection
 from reactpy.backend.types import Connection, Location
+from reactpy.core.component import Component
 from reactpy.types import ComponentType, VdomDict
 
 from reactpy_router.components import FirstLoad, History
-from reactpy_router.hooks import _route_state_context, _RouteState
+from reactpy_router.hooks import RouteState, _route_state_context
 from reactpy_router.resolvers import StarletteResolver
 from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType
 
@@ -23,15 +24,27 @@
 def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]:
     """A decorator that turns a resolver into a router"""
 
-    def wrapper(*routes: RouteType) -> ComponentType:
+    def wrapper(*routes: RouteType) -> Component:
         return router(*routes, resolver=resolver)
 
     return wrapper
 
 
-browser_router = create_router(StarletteResolver)
-"""This is the recommended router for all ReactPy Router web projects.
-It uses the JavaScript DOM History API to manage the history stack."""
+_starlette_router = create_router(StarletteResolver)
+
+
+def browser_router(*routes: RouteType) -> Component:
+    """This is the recommended router for all ReactPy-Router web projects.
+    It uses the JavaScript [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)
+    to manage the history stack.
+
+    Args:
+        *routes (RouteType): A list of routes to be rendered by the router.
+
+    Returns:
+        A router component that renders the given routes.
+    """
+    return _starlette_router(*routes)
 
 
 @component
@@ -59,7 +72,7 @@ def router(
         route_elements = [
             _route_state_context(
                 element,
-                value=_RouteState(set_location, params),
+                value=RouteState(set_location, params),
             )
             for element, params in match
         ]
diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py
index 15a77c4..87f7d7f 100644
--- a/src/reactpy_router/types.py
+++ b/src/reactpy_router/types.py
@@ -5,26 +5,36 @@
 from dataclasses import dataclass, field
 from typing import Any, Callable, Sequence, TypedDict, TypeVar
 
+from reactpy.backend.types import Location
+from reactpy.core.component import Component
 from reactpy.core.vdom import is_vdom
-from reactpy.types import ComponentType, Key
+from reactpy.types import Key
 from typing_extensions import Protocol, Self, TypeAlias
 
 ConversionFunc: TypeAlias = Callable[[str], Any]
+"""A function that converts a string to a specific type."""
+
 ConverterMapping: TypeAlias = dict[str, ConversionFunc]
+"""A mapping of conversion types to their respective functions."""
 
 
 @dataclass(frozen=True)
 class Route:
-    """A route that can be matched against a path."""
+    """
+    A class representing a route that can be matched against a path.
 
-    path: str
-    """The path to match against."""
+    Attributes:
+        path (str): The path to match against.
+        element (Any): The element to render if the path matches.
+        routes (Sequence[Self]): Child routes.
 
-    element: Any = field(hash=False)
-    """The element to render if the path matches."""
+    Methods:
+        __hash__() -> int: Returns a hash value for the route based on its path, element, and child routes.
+    """
 
+    path: str
+    element: Any = field(hash=False)
     routes: Sequence[Self]
-    """Child routes."""
 
     def __hash__(self) -> int:
         el = self.element
@@ -33,36 +43,87 @@ def __hash__(self) -> int:
 
 
 RouteType = TypeVar("RouteType", bound=Route)
+"""A type variable for `Route`."""
+
 RouteType_contra = TypeVar("RouteType_contra", bound=Route, contravariant=True)
+"""A contravariant type variable for `Route`."""
 
 
 class Router(Protocol[RouteType_contra]):
-    """Return a component that renders the first matching route."""
+    """Return a component that renders the matching route(s)."""
+
+    def __call__(self, *routes: RouteType_contra) -> Component:
+        """
+        Process the given routes and return a component that renders the matching route(s).
 
-    def __call__(self, *routes: RouteType_contra) -> ComponentType: ...
+        Args:
+            *routes: A variable number of route arguments.
+
+        Returns:
+            The resulting component after processing the routes.
+        """
 
 
 class Resolver(Protocol[RouteType_contra]):
     """Compile a route into a resolver that can be matched against a given path."""
 
-    def __call__(self, route: RouteType_contra) -> CompiledRoute: ...
+    def __call__(self, route: RouteType_contra) -> CompiledRoute:
+        """
+        Compile a route into a resolver that can be matched against a given path.
+
+        Args:
+            route: The route to compile.
+
+        Returns:
+            The compiled route.
+        """
 
 
 class CompiledRoute(Protocol):
-    """A compiled route that can be matched against a path."""
+    """
+    A protocol for a compiled route that can be matched against a path.
+
+    Attributes:
+        key (Key): A property that uniquely identifies this resolver.
+    """
 
     @property
-    def key(self) -> Key:
-        """Uniquely identified this resolver."""
+    def key(self) -> Key: ...
 
     def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
-        """Return the path's associated element and path parameters or None."""
+        """
+        Return the path's associated element and path parameters or None.
+
+        Args:
+            path (str): The path to resolve.
+
+        Returns:
+            A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved.
+        """
 
 
 class ConversionInfo(TypedDict):
-    """Information about a conversion type."""
+    """
+    A TypedDict that holds information about a conversion type.
+
+    Attributes:
+        regex (str): The regex to match the conversion type.
+        func (ConversionFunc): The function to convert the matched string to the expected type.
+    """
 
     regex: str
-    """The regex to match the conversion type."""
     func: ConversionFunc
-    """The function to convert the matched string to the expected type."""
+
+
+@dataclass
+class RouteState:
+    """
+    Represents the state of a route in the application.
+
+    Attributes:
+        set_location: A callable to set the location.
+        params: A dictionary containing route parameters.
+    """
+
+    set_location: Callable[[Location], None]
+    params: dict[str, Any]

From 0e4304c2b6dc664d0531898a3820326e2ce16c12 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Oct 2024 00:15:04 -0700
Subject: [PATCH 27/27] Format all docs examples

---
 docs/examples/python/basic-routing-more-routes.py | 1 +
 docs/examples/python/basic-routing.py             | 1 +
 2 files changed, 2 insertions(+)

diff --git a/docs/examples/python/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py
index 32bb31e..14c9b5a 100644
--- a/docs/examples/python/basic-routing-more-routes.py
+++ b/docs/examples/python/basic-routing-more-routes.py
@@ -1,4 +1,5 @@
 from reactpy import component, html, run
+
 from reactpy_router import browser_router, route
 
 
diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py
index 43c4e65..efc7835 100644
--- a/docs/examples/python/basic-routing.py
+++ b/docs/examples/python/basic-routing.py
@@ -1,4 +1,5 @@
 from reactpy import component, html, run
+
 from reactpy_router import browser_router, route