From 15e7804374a098c05696aa51d26fc4d6445e7832 Mon Sep 17 00:00:00 2001
From: rmorshea <ryan.morshead@gmail.com>
Date: Thu, 20 Oct 2022 00:52:24 -0700
Subject: [PATCH 1/6] initial work on router compiler

---
 idom_router/__init__.py |   8 ++-
 idom_router/router.py   | 108 +++++++++++++---------------------------
 idom_router/types.py    |  28 +++++++++++
 tests/test_router.py    |  71 ++++++++++++++++++--------
 4 files changed, 115 insertions(+), 100 deletions(-)
 create mode 100644 idom_router/types.py

diff --git a/idom_router/__init__.py b/idom_router/__init__.py
index 2ccf316..472e2a9 100644
--- a/idom_router/__init__.py
+++ b/idom_router/__init__.py
@@ -3,19 +3,17 @@
 
 from .router import (
     Route,
-    RouterConstructor,
-    create_router,
     link,
+    router,
     use_location,
     use_params,
     use_query,
 )
 
 __all__ = [
-    "create_router",
-    "link",
     "Route",
-    "RouterConstructor",
+    "link",
+    "router",
     "use_location",
     "use_params",
     "use_query",
diff --git a/idom_router/router.py b/idom_router/router.py
index af6c84f..efadfa6 100644
--- a/idom_router/router.py
+++ b/idom_router/router.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import re
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Any, Callable, Iterator, Sequence
@@ -9,9 +8,11 @@
 from idom import component, create_context, use_context, use_memo, use_state
 from idom.core.types import VdomAttributesAndChildren, VdomDict
 from idom.core.vdom import coalesce_attributes_and_children
-from idom.types import BackendImplementation, ComponentType, Context, Location
+from idom.types import ComponentType, Context, Location
 from idom.web.module import export, module_from_file
-from starlette.routing import compile_path
+from starlette.routing import compile_path as _compile_starlette_path
+
+from idom_router.types import RoutePattern, RouteCompiler, Route
 
 try:
     from typing import Protocol
@@ -19,58 +20,38 @@
     from typing_extensions import Protocol  # type: ignore
 
 
-class RouterConstructor(Protocol):
-    def __call__(self, *routes: Route) -> ComponentType:
-        ...
-
-
-def create_router(
-    implementation: BackendImplementation[Any] | Callable[[], Location]
-) -> RouterConstructor:
-    if isinstance(implementation, BackendImplementation):
-        use_location = implementation.use_location
-    elif callable(implementation):
-        use_location = implementation
-    else:
-        raise TypeError(
-            "Expected a 'BackendImplementation' or "
-            f"'use_location' hook, not {implementation}"
-        )
-
-    @component
-    def router(*routes: Route) -> ComponentType | None:
-        initial_location = use_location()
-        location, set_location = use_state(initial_location)
-        compiled_routes = use_memo(
-            lambda: _iter_compile_routes(routes), dependencies=routes
-        )
-        for r in compiled_routes:
-            match = r.pattern.match(location.pathname)
-            if match:
-                return _LocationStateContext(
-                    r.element,
-                    value=_LocationState(
-                        location,
-                        set_location,
-                        {k: r.converters[k](v) for k, v in match.groupdict().items()},
-                    ),
-                    key=r.pattern.pattern,
-                )
-        return None
-
-    return router
-
+def compile_starlette_route(route: str) -> RoutePattern:
+    pattern, _, converters = _compile_starlette_path(route)
+    return RoutePattern(pattern, {k: v.convert for k, v in converters.items()})
 
-@dataclass
-class Route:
-    path: str
-    element: Any
-    routes: Sequence[Route]
 
-    def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
-        self.path = path
-        self.element = element
-        self.routes = routes
+@component
+def router(
+    *routes: Route,
+    compiler: RouteCompiler = compile_starlette_route,
+) -> ComponentType | None:
+    initial_location = use_location()
+    location, set_location = use_state(initial_location)
+    compiled_routes = use_memo(
+        lambda: [(compiler(r), e) for r, e in _iter_routes(routes)],
+        dependencies=routes,
+    )
+    for compiled_route, element in compiled_routes:
+        match = compiled_route.pattern.match(location.pathname)
+        if match:
+            return _LocationStateContext(
+                element,
+                value=_LocationState(
+                    location,
+                    set_location,
+                    {
+                        k: compiled_route.converters[k](v)
+                        for k, v in match.groupdict().items()
+                    },
+                ),
+                key=compiled_route.pattern.pattern,
+            )
+    return None
 
 
 @component
@@ -113,14 +94,6 @@ def use_query(
     )
 
 
-def _iter_compile_routes(routes: Sequence[Route]) -> Iterator[_CompiledRoute]:
-    for path, element in _iter_routes(routes):
-        pattern, _, converters = compile_path(path)
-        yield _CompiledRoute(
-            pattern, {k: v.convert for k, v in converters.items()}, element
-        )
-
-
 def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
     for r in routes:
         for path, element in _iter_routes(r.routes):
@@ -128,13 +101,6 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
         yield r.path, r.element
 
 
-@dataclass
-class _CompiledRoute:
-    pattern: re.Pattern[str]
-    converters: dict[str, Callable[[Any], Any]]
-    element: Any
-
-
 def _use_location_state() -> _LocationState:
     location_state = use_context(_LocationStateContext)
     assert location_state is not None, "No location state. Did you use a Router?"
@@ -151,10 +117,6 @@ class _LocationState:
 _LocationStateContext: Context[_LocationState | None] = create_context(None)
 
 _Link = export(
-    module_from_file(
-        "idom-router",
-        file=Path(__file__).parent / "bundle.js",
-        fallback="⏳",
-    ),
+    module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"),
     "Link",
 )
diff --git a/idom_router/types.py b/idom_router/types.py
new file mode 100644
index 0000000..ba473f9
--- /dev/null
+++ b/idom_router/types.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Callable, Any, Protocol, Sequence
+
+
+@dataclass
+class Route:
+    path: str
+    element: Any
+    routes: Sequence[Route]
+
+    def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
+        self.path = path
+        self.element = element
+        self.routes = routes
+
+
+class RouteCompiler(Protocol):
+    def __call__(self, route: str) -> RoutePattern:
+        ...
+
+
+@dataclass
+class RoutePattern:
+    pattern: re.Pattern[str]
+    converters: dict[str, Callable[[Any], Any]]
diff --git a/tests/test_router.py b/tests/test_router.py
index 378ba28..f958854 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -1,33 +1,19 @@
 import pytest
 from idom import Ref, component, html
-from idom.testing import BackendFixture, DisplayFixture
+from idom.testing import DisplayFixture
 
 from idom_router import (
     Route,
-    RouterConstructor,
-    create_router,
+    router,
     link,
     use_location,
     use_params,
     use_query,
 )
+from idom_router.types import RoutePattern
 
 
-@pytest.fixture
-def router(backend: BackendFixture):
-    return create_router(backend.implementation)
-
-
-def test_create_router(backend):
-    create_router(backend.implementation)
-    create_router(backend.implementation.use_location)
-    with pytest.raises(
-        TypeError, match="Expected a 'BackendImplementation' or 'use_location' hook"
-    ):
-        create_router(None)
-
-
-async def test_simple_router(display: DisplayFixture, router: RouterConstructor):
+async def test_simple_router(display: DisplayFixture):
     def make_location_check(path, *routes):
         name = path.lstrip("/").replace("/", "-")
 
@@ -68,7 +54,7 @@ def sample():
     assert not await root_element.inner_html()
 
 
-async def test_nested_routes(display: DisplayFixture, router: RouterConstructor):
+async def test_nested_routes(display: DisplayFixture):
     @component
     def sample():
         return router(
@@ -94,7 +80,7 @@ def sample():
         await display.page.wait_for_selector(selector)
 
 
-async def test_navigate_with_link(display: DisplayFixture, router: RouterConstructor):
+async def test_navigate_with_link(display: DisplayFixture):
     render_count = Ref(0)
 
     @component
@@ -121,7 +107,7 @@ def sample():
     assert render_count.current == 1
 
 
-async def test_use_params(display: DisplayFixture, router: RouterConstructor):
+async def test_use_params(display: DisplayFixture):
     expected_params = {}
 
     @component
@@ -157,7 +143,7 @@ def sample():
         await display.page.wait_for_selector("#success")
 
 
-async def test_use_query(display: DisplayFixture, router: RouterConstructor):
+async def test_use_query(display: DisplayFixture):
     expected_query = {}
 
     @component
@@ -174,3 +160,44 @@ def sample():
     expected_query = {"hello": ["world"], "thing": ["1", "2"]}
     await display.goto("?hello=world&thing=1&thing=2")
     await display.page.wait_for_selector("#success")
+
+
+def custom_path_compiler(path):
+    pattern = re.compile(path)
+
+
+async def test_custom_path_compiler(display: DisplayFixture):
+    expected_params = {}
+
+    @component
+    def check_params():
+        assert use_params() == expected_params
+        return html.h1({"id": "success"}, "success")
+
+    @component
+    def sample():
+        return router(
+            Route(
+                "/first/{first:str}",
+                check_params(),
+                Route(
+                    "/second/{second:str}",
+                    check_params(),
+                    Route(
+                        "/third/{third:str}",
+                        check_params(),
+                    ),
+                ),
+            ),
+            compiler=lambda path: RoutePattern(re.compile()),
+        )
+
+    await display.show(sample)
+
+    for path, expected_params in [
+        ("/first/1", {"first": "1"}),
+        ("/first/1/second/2", {"first": "1", "second": "2"}),
+        ("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}),
+    ]:
+        await display.goto(path)
+        await display.page.wait_for_selector("#success")

From 6d7254f146402b746c52e26918a6f56f181dc7fc Mon Sep 17 00:00:00 2001
From: rmorshea <ryan.morshead@gmail.com>
Date: Sun, 30 Oct 2022 17:06:40 -0700
Subject: [PATCH 2/6] fix tests and upgrade to compat latest idom ver

---
 idom_router/router.py | 77 ++++++++++++++++++++++---------------------
 tests/test_router.py  | 33 ++++++++++---------
 2 files changed, 57 insertions(+), 53 deletions(-)

diff --git a/idom_router/router.py b/idom_router/router.py
index efadfa6..4c70423 100644
--- a/idom_router/router.py
+++ b/idom_router/router.py
@@ -5,20 +5,24 @@
 from typing import Any, Callable, Iterator, Sequence
 from urllib.parse import parse_qs
 
-from idom import component, create_context, use_context, use_memo, use_state
+from idom import (
+    component,
+    create_context,
+    use_memo,
+    use_state,
+    use_context,
+    use_location,
+)
 from idom.core.types import VdomAttributesAndChildren, VdomDict
 from idom.core.vdom import coalesce_attributes_and_children
-from idom.types import ComponentType, Context, Location
+from idom.types import ComponentType, Location, Context
 from idom.web.module import export, module_from_file
+from idom.backend.hooks import ConnectionContext, use_connection
+from idom.backend.types import Connection, Location
 from starlette.routing import compile_path as _compile_starlette_path
 
 from idom_router.types import RoutePattern, RouteCompiler, Route
 
-try:
-    from typing import Protocol
-except ImportError:  # pragma: no cover
-    from typing_extensions import Protocol  # type: ignore
-
 
 def compile_starlette_route(route: str) -> RoutePattern:
     pattern, _, converters = _compile_starlette_path(route)
@@ -30,8 +34,9 @@ def router(
     *routes: Route,
     compiler: RouteCompiler = compile_starlette_route,
 ) -> ComponentType | None:
-    initial_location = use_location()
-    location, set_location = use_state(initial_location)
+    old_conn = use_connection()
+    location, set_location = use_state(old_conn.location)
+
     compiled_routes = use_memo(
         lambda: [(compiler(r), e) for r, e in _iter_routes(routes)],
         dependencies=routes,
@@ -39,16 +44,19 @@ def router(
     for compiled_route, element in compiled_routes:
         match = compiled_route.pattern.match(location.pathname)
         if match:
-            return _LocationStateContext(
-                element,
-                value=_LocationState(
-                    location,
-                    set_location,
-                    {
-                        k: compiled_route.converters[k](v)
-                        for k, v in match.groupdict().items()
-                    },
+            convs = compiled_route.converters
+            return ConnectionContext(
+                _route_state_context(
+                    element,
+                    value=_RouteState(
+                        set_location,
+                        {
+                            k: convs[k](v) if k in convs else v
+                            for k, v in match.groupdict().items()
+                        },
+                    ),
                 ),
+                value=Connection(old_conn.scope, location, old_conn.carrier),
                 key=compiled_route.pattern.pattern,
             )
     return None
@@ -57,23 +65,18 @@ def router(
 @component
 def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict:
     attributes, children = coalesce_attributes_and_children(attributes_or_children)
-    set_location = _use_location_state().set_location
+    set_location = _use_route_state().set_location
     attrs = {
         **attributes,
         "to": to,
         "onClick": lambda event: set_location(Location(**event)),
     }
-    return _Link(attrs, *children)
-
-
-def use_location() -> Location:
-    """Get the current route location"""
-    return _use_location_state().location
+    return _link(attrs, *children)
 
 
 def use_params() -> dict[str, Any]:
     """Get parameters from the currently matching route pattern"""
-    return _use_location_state().params
+    return use_context(_route_state_context).params
 
 
 def use_query(
@@ -94,6 +97,10 @@ def use_query(
     )
 
 
+def _use_route_state() -> _RouteState:
+    return use_context(_route_state_context)
+
+
 def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
     for r in routes:
         for path, element in _iter_routes(r.routes):
@@ -101,22 +108,16 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
         yield r.path, r.element
 
 
-def _use_location_state() -> _LocationState:
-    location_state = use_context(_LocationStateContext)
-    assert location_state is not None, "No location state. Did you use a Router?"
-    return location_state
+_link = export(
+    module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"),
+    "Link",
+)
 
 
 @dataclass
-class _LocationState:
-    location: Location
+class _RouteState:
     set_location: Callable[[Location], None]
     params: dict[str, Any]
 
 
-_LocationStateContext: Context[_LocationState | None] = create_context(None)
-
-_Link = export(
-    module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"),
-    "Link",
-)
+_route_state_context: Context[_RouteState | None] = create_context(None)
diff --git a/tests/test_router.py b/tests/test_router.py
index f958854..dc8f53b 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -1,12 +1,12 @@
-import pytest
-from idom import Ref, component, html
+import re
+
+from idom import Ref, component, html, use_location
 from idom.testing import DisplayFixture
 
 from idom_router import (
     Route,
     router,
     link,
-    use_location,
     use_params,
     use_query,
 )
@@ -45,7 +45,7 @@ def sample():
     await display.goto("/missing")
 
     try:
-        root_element = display.root_element()
+        root_element = await display.root_element()
     except AttributeError:
         root_element = await display.page.wait_for_selector(
             f"#display-{display._next_view_id}", state="attached"
@@ -162,10 +162,6 @@ def sample():
     await display.page.wait_for_selector("#success")
 
 
-def custom_path_compiler(path):
-    pattern = re.compile(path)
-
-
 async def test_custom_path_compiler(display: DisplayFixture):
     expected_params = {}
 
@@ -178,26 +174,33 @@ def check_params():
     def sample():
         return router(
             Route(
-                "/first/{first:str}",
+                r"/first/(?P<first>\d+)",
                 check_params(),
                 Route(
-                    "/second/{second:str}",
+                    r"/second/(?P<second>[\d\.]+)",
                     check_params(),
                     Route(
-                        "/third/{third:str}",
+                        r"/third/(?P<third>[\d,]+)",
                         check_params(),
                     ),
                 ),
             ),
-            compiler=lambda path: RoutePattern(re.compile()),
+            compiler=lambda path: RoutePattern(
+                re.compile(rf"^{path}$"),
+                {
+                    "first": int,
+                    "second": float,
+                    "third": lambda s: list(map(int, s.split(","))),
+                },
+            ),
         )
 
     await display.show(sample)
 
     for path, expected_params in [
-        ("/first/1", {"first": "1"}),
-        ("/first/1/second/2", {"first": "1", "second": "2"}),
-        ("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}),
+        ("/first/1", {"first": 1}),
+        ("/first/1/second/2.1", {"first": 1, "second": 2.1}),
+        ("/first/1/second/2.1/third/3,3", {"first": 1, "second": 2.1, "third": [3, 3]}),
     ]:
         await display.goto(path)
         await display.page.wait_for_selector("#success")

From 54d6776f6ca029b41da71820a893b29ee9fe5197 Mon Sep 17 00:00:00 2001
From: rmorshea <ryan.morshead@gmail.com>
Date: Sat, 29 Apr 2023 15:21:52 -0600
Subject: [PATCH 3/6] rework route compiler interface

This allows compilers to work in a wider variety of ways.
---
 idom_router/__init__.py   |  15 +++---
 idom_router/compilers.py  |  34 +++++++++++++
 idom_router/router.py     | 102 ++++++++++++++++++++------------------
 idom_router/types.py      |  41 ++++++++++-----
 requirements/pkg-deps.txt |   2 +-
 tests/test_router.py      |  40 ++++++---------
 tests/utils.py            |  49 ++++++++++++++++++
 7 files changed, 186 insertions(+), 97 deletions(-)
 create mode 100644 idom_router/compilers.py
 create mode 100644 tests/utils.py

diff --git a/idom_router/__init__.py b/idom_router/__init__.py
index 472e2a9..1859ab9 100644
--- a/idom_router/__init__.py
+++ b/idom_router/__init__.py
@@ -1,18 +1,15 @@
 # the version is statically loaded by setup.py
 __version__ = "0.0.1"
 
-from .router import (
-    Route,
-    link,
-    router,
-    use_location,
-    use_params,
-    use_query,
-)
+from idom_router.types import Route, RouteCompiler, RoutePattern
+
+from .router import link, router, use_params, use_query
 
 __all__ = [
-    "Route",
     "link",
+    "Route",
+    "RouteCompiler",
+    "RoutePattern",
     "router",
     "use_location",
     "use_params",
diff --git a/idom_router/compilers.py b/idom_router/compilers.py
new file mode 100644
index 0000000..76d5b52
--- /dev/null
+++ b/idom_router/compilers.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from starlette.convertors import Convertor
+from starlette.routing import compile_path as _compile_starlette_path
+
+from idom_router.types import Route
+
+
+def compile_starlette_route(route: Route) -> StarletteRoutePattern:
+    pattern, _, converters = _compile_starlette_path(route.path)
+    return StarletteRoutePattern(pattern, converters)
+
+
+class StarletteRoutePattern:
+    def __init__(
+        self,
+        pattern: re.Pattern[str],
+        converters: dict[str, Convertor],
+    ) -> None:
+        self.pattern = pattern
+        self.key = pattern.pattern
+        self.converters = converters
+
+    def match(self, path: str) -> dict[str, Any] | None:
+        match = self.pattern.match(path)
+        if match:
+            return {
+                k: self.converters[k].convert(v) if k in self.converters else v
+                for k, v in match.groupdict().items()
+            }
+        return None
diff --git a/idom_router/router.py b/idom_router/router.py
index 4c70423..bdb773d 100644
--- a/idom_router/router.py
+++ b/idom_router/router.py
@@ -1,70 +1,58 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
 from pathlib import Path
-from typing import Any, Callable, Iterator, Sequence
+from typing import Any, Callable, Iterator, Sequence, TypeVar
 from urllib.parse import parse_qs
 
 from idom import (
     component,
     create_context,
-    use_memo,
-    use_state,
     use_context,
     use_location,
+    use_memo,
+    use_state,
 )
-from idom.core.types import VdomAttributesAndChildren, VdomDict
-from idom.core.vdom import coalesce_attributes_and_children
-from idom.types import ComponentType, Location, Context
-from idom.web.module import export, module_from_file
 from idom.backend.hooks import ConnectionContext, use_connection
 from idom.backend.types import Connection, Location
-from starlette.routing import compile_path as _compile_starlette_path
-
-from idom_router.types import RoutePattern, RouteCompiler, Route
+from idom.core.types import VdomChild, VdomDict
+from idom.types import ComponentType, Context, Location
+from idom.web.module import export, module_from_file
 
+from idom_router.compilers import compile_starlette_route
+from idom_router.types import Route, RouteCompiler, RoutePattern
 
-def compile_starlette_route(route: str) -> RoutePattern:
-    pattern, _, converters = _compile_starlette_path(route)
-    return RoutePattern(pattern, {k: v.convert for k, v in converters.items()})
+R = TypeVar("R", bound=Route)
 
 
 @component
 def router(
-    *routes: Route,
-    compiler: RouteCompiler = compile_starlette_route,
+    *routes: R,
+    compiler: RouteCompiler[R] = compile_starlette_route,
 ) -> ComponentType | None:
     old_conn = use_connection()
     location, set_location = use_state(old_conn.location)
 
-    compiled_routes = use_memo(
-        lambda: [(compiler(r), e) for r, e in _iter_routes(routes)],
-        dependencies=routes,
-    )
-    for compiled_route, element in compiled_routes:
-        match = compiled_route.pattern.match(location.pathname)
-        if match:
-            convs = compiled_route.converters
-            return ConnectionContext(
-                _route_state_context(
-                    element,
-                    value=_RouteState(
-                        set_location,
-                        {
-                            k: convs[k](v) if k in convs else v
-                            for k, v in match.groupdict().items()
-                        },
-                    ),
-                ),
-                value=Connection(old_conn.scope, location, old_conn.carrier),
-                key=compiled_route.pattern.pattern,
-            )
+    # Memoize the compiled routes and the match separately so that we don't
+    # recompile the routes on renders where only the location has changed
+    compiled_routes = use_memo(lambda: _compile_routes(routes, compiler))
+    match = use_memo(lambda: _match_route(compiled_routes, location))
+
+    if match is not None:
+        route, params = match
+        return ConnectionContext(
+            _route_state_context(
+                route.element, value=_RouteState(set_location, params)
+            ),
+            value=Connection(old_conn.scope, location, old_conn.carrier),
+            key=route.path,
+        )
+
     return None
 
 
 @component
-def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict:
-    attributes, children = coalesce_attributes_and_children(attributes_or_children)
+def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict:
     set_location = _use_route_state().set_location
     attrs = {
         **attributes,
@@ -76,7 +64,7 @@ def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDic
 
 def use_params() -> dict[str, Any]:
     """Get parameters from the currently matching route pattern"""
-    return use_context(_route_state_context).params
+    return _use_route_state().params
 
 
 def use_query(
@@ -97,15 +85,27 @@ def use_query(
     )
 
 
-def _use_route_state() -> _RouteState:
-    return use_context(_route_state_context)
+def _compile_routes(
+    routes: Sequence[R], compiler: RouteCompiler[R]
+) -> list[tuple[Any, RoutePattern]]:
+    return [(r, compiler(r)) for r in _iter_routes(routes)]
+
+
+def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
+    for parent in routes:
+        for child in _iter_routes(parent.routes):
+            yield replace(child, path=parent.path + child.path)
+        yield parent
 
 
-def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
-    for r in routes:
-        for path, element in _iter_routes(r.routes):
-            yield r.path + path, element
-        yield r.path, r.element
+def _match_route(
+    compiled_routes: list[tuple[R, RoutePattern]], location: Location
+) -> tuple[R, dict[str, Any]] | None:
+    for route, pattern in compiled_routes:
+        params = pattern.match(location.pathname)
+        if params is not None:  # explicitely None check (could be empty dict)
+            return route, params
+    return None
 
 
 _link = export(
@@ -120,4 +120,10 @@ class _RouteState:
     params: dict[str, Any]
 
 
+def _use_route_state() -> _RouteState:
+    route_state = use_context(_route_state_context)
+    assert route_state is not None
+    return route_state
+
+
 _route_state_context: Context[_RouteState | None] = create_context(None)
diff --git a/idom_router/types.py b/idom_router/types.py
index ba473f9..a86c45e 100644
--- a/idom_router/types.py
+++ b/idom_router/types.py
@@ -1,28 +1,43 @@
 from __future__ import annotations
 
-import re
 from dataclasses import dataclass
-from typing import Callable, Any, Protocol, Sequence
+from typing import Any, Sequence, TypeVar
+
+from idom.types import Key
+from typing_extensions import Protocol, Self
 
 
 @dataclass
 class Route:
     path: str
     element: Any
-    routes: Sequence[Route]
-
-    def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
+    routes: Sequence[Self]
+
+    def __init__(
+        self,
+        path: str,
+        element: Any | None,
+        *routes_: Self,
+        # we need kwarg in order to play nice with the expected dataclass interface
+        routes: Sequence[Self] = (),
+    ) -> None:
         self.path = path
         self.element = element
-        self.routes = routes
+        self.routes = (*routes_, *routes)
 
 
-class RouteCompiler(Protocol):
-    def __call__(self, route: str) -> RoutePattern:
-        ...
+R = TypeVar("R", bound=Route, contravariant=True)
 
 
-@dataclass
-class RoutePattern:
-    pattern: re.Pattern[str]
-    converters: dict[str, Callable[[Any], Any]]
+class RouteCompiler(Protocol[R]):
+    def __call__(self, route: R) -> RoutePattern:
+        """Compile a route into a pattern that can be matched against a path"""
+
+
+class RoutePattern(Protocol):
+    @property
+    def key(self) -> Key:
+        """Uniquely identified this pattern"""
+
+    def match(self, path: str) -> dict[str, Any] | None:
+        """Returns otherwise a dict of path parameters if the path matches, else None"""
diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt
index a61b19f..c205c19 100644
--- a/requirements/pkg-deps.txt
+++ b/requirements/pkg-deps.txt
@@ -1,3 +1,3 @@
-idom >=0.40.2,<0.41
+idom >=1
 typing_extensions
 starlette
diff --git a/tests/test_router.py b/tests/test_router.py
index dc8f53b..ce05220 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -1,16 +1,8 @@
-import re
-
 from idom import Ref, component, html, use_location
 from idom.testing import DisplayFixture
 
-from idom_router import (
-    Route,
-    router,
-    link,
-    use_params,
-    use_query,
-)
-from idom_router.types import RoutePattern
+from idom_router import Route, link, router, use_params, use_query
+from tests.utils import compile_simple_regex_route
 
 
 async def test_simple_router(display: DisplayFixture):
@@ -87,10 +79,10 @@ async def test_navigate_with_link(display: DisplayFixture):
     def sample():
         render_count.current += 1
         return router(
-            Route("/", link({"id": "root"}, "Root", to="/a")),
-            Route("/a", link({"id": "a"}, "A", to="/b")),
-            Route("/b", link({"id": "b"}, "B", to="/c")),
-            Route("/c", link({"id": "c"}, "C", to="/default")),
+            Route("/", link("Root", to="/a", id="root")),
+            Route("/a", link("A", to="/b", id="a")),
+            Route("/b", link("B", to="/c", id="b")),
+            Route("/c", link("C", to="/default", id="c")),
             Route("/{path:path}", html.h1({"id": "default"}, "Default")),
         )
 
@@ -174,25 +166,18 @@ def check_params():
     def sample():
         return router(
             Route(
-                r"/first/(?P<first>\d+)",
+                r"/first/(?P<first__int>\d+)",
                 check_params(),
                 Route(
-                    r"/second/(?P<second>[\d\.]+)",
+                    r"/second/(?P<second__float>[\d\.]+)",
                     check_params(),
                     Route(
-                        r"/third/(?P<third>[\d,]+)",
+                        r"/third/(?P<third__list>[\d,]+)",
                         check_params(),
                     ),
                 ),
             ),
-            compiler=lambda path: RoutePattern(
-                re.compile(rf"^{path}$"),
-                {
-                    "first": int,
-                    "second": float,
-                    "third": lambda s: list(map(int, s.split(","))),
-                },
-            ),
+            compiler=compile_simple_regex_route,
         )
 
     await display.show(sample)
@@ -200,7 +185,10 @@ def sample():
     for path, expected_params in [
         ("/first/1", {"first": 1}),
         ("/first/1/second/2.1", {"first": 1, "second": 2.1}),
-        ("/first/1/second/2.1/third/3,3", {"first": 1, "second": 2.1, "third": [3, 3]}),
+        (
+            "/first/1/second/2.1/third/3,3",
+            {"first": 1, "second": 2.1, "third": ["3", "3"]},
+        ),
     ]:
         await display.goto(path)
         await display.page.wait_for_selector("#success")
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..d0fdc9f
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import re
+from typing import Any, Callable
+
+from idom_router import Route
+
+
+def compile_simple_regex_route(route: Route) -> RegexRoutePattern:
+    """Compile simple regex route.
+
+    Named regex groups can end with a `__type` suffix to specify a type converter
+
+    For example, `(?P<id__int>[0-9]+)` will convert the `id` parameter to an `int`.
+
+    Supported types are `int`, `float`, and `list` where `list` will split on `,`.
+    """
+    pattern = re.compile(route.path)
+    return RegexRoutePattern(pattern)
+
+
+class RegexRoutePattern:
+    def __init__(self, pattern: re.Pattern) -> None:
+        self.pattern = pattern
+        self.key = pattern.pattern
+
+    def match(self, path: str) -> dict[str, str] | None:
+        match = self.pattern.match(path)
+        if match:
+            params: dict[str, Any] = {}
+            for k, v in match.groupdict().items():
+                name, _, type_ = k.partition("__")
+                try:
+                    params[name] = CONVERTERS.get(type_, DEFAULT_CONVERTER)(v)
+                except ValueError:
+                    return None
+            return params
+        return None
+
+
+CONVERTERS: dict[str, Callable[[str], Any]] = {
+    "int": int,
+    "float": float,
+    "list": lambda s: s.split(","),
+}
+
+
+def DEFAULT_CONVERTER(s: str) -> str:
+    return s

From 540d0ced64d00ee944a5e570be98fb69d662094f Mon Sep 17 00:00:00 2001
From: rmorshea <ryan.morshead@gmail.com>
Date: Sat, 6 May 2023 00:15:28 -0600
Subject: [PATCH 4/6] rework based on feedback

---
 idom_router/__init__.py                       |  9 ++++---
 idom_router/{router.py => core.py}            | 25 ++++++++++++++-----
 idom_router/routers/__init__.py               |  0
 .../utils.py => idom_router/routers/regex.py  |  7 +++---
 .../{compilers.py => routers/starlette.py}    | 14 +++++++----
 idom_router/types.py                          | 21 ++++++++++------
 6 files changed, 51 insertions(+), 25 deletions(-)
 rename idom_router/{router.py => core.py} (83%)
 create mode 100644 idom_router/routers/__init__.py
 rename tests/utils.py => idom_router/routers/regex.py (86%)
 rename idom_router/{compilers.py => routers/starlette.py} (66%)

diff --git a/idom_router/__init__.py b/idom_router/__init__.py
index 1859ab9..7ef08c0 100644
--- a/idom_router/__init__.py
+++ b/idom_router/__init__.py
@@ -1,16 +1,17 @@
 # the version is statically loaded by setup.py
 __version__ = "0.0.1"
 
-from idom_router.types import Route, RouteCompiler, RoutePattern
+from idom_router.types import Route, RouteCompiler, RouteResolver
 
-from .router import link, router, use_params, use_query
+from .core import link, create_router, router_component, use_params, use_query
 
 __all__ = [
+    "create_router",
     "link",
     "Route",
     "RouteCompiler",
-    "RoutePattern",
-    "router",
+    "router_component",
+    "RouteResolver",
     "use_location",
     "use_params",
     "use_query",
diff --git a/idom_router/router.py b/idom_router/core.py
similarity index 83%
rename from idom_router/router.py
rename to idom_router/core.py
index bdb773d..73b32a9 100644
--- a/idom_router/router.py
+++ b/idom_router/core.py
@@ -19,19 +19,32 @@
 from idom.types import ComponentType, Context, Location
 from idom.web.module import export, module_from_file
 
-from idom_router.compilers import compile_starlette_route
-from idom_router.types import Route, RouteCompiler, RoutePattern
+from idom_router.types import Route, RouteCompiler, RouteResolver, Router
 
 R = TypeVar("R", bound=Route)
 
 
+def create_router(compiler: RouteCompiler[R]) -> Router[R]:
+    """A decorator that turns a route compiler into a router"""
+
+    def wrapper(*routes: R) -> ComponentType:
+        return router_component(*routes, compiler=compiler)
+
+    return wrapper
+
+
 @component
-def router(
+def router_component(
     *routes: R,
-    compiler: RouteCompiler[R] = compile_starlette_route,
+    compiler: RouteCompiler[R],
 ) -> ComponentType | None:
     old_conn = use_connection()
     location, set_location = use_state(old_conn.location)
+    router_state = use_context(_route_state_context)
+
+
+    if router_state is not None:
+        raise RuntimeError("Another router is already active in this context")
 
     # Memoize the compiled routes and the match separately so that we don't
     # recompile the routes on renders where only the location has changed
@@ -87,7 +100,7 @@ def use_query(
 
 def _compile_routes(
     routes: Sequence[R], compiler: RouteCompiler[R]
-) -> list[tuple[Any, RoutePattern]]:
+) -> list[tuple[Any, RouteResolver]]:
     return [(r, compiler(r)) for r in _iter_routes(routes)]
 
 
@@ -99,7 +112,7 @@ def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
 
 
 def _match_route(
-    compiled_routes: list[tuple[R, RoutePattern]], location: Location
+    compiled_routes: list[tuple[R, RouteResolver]], location: Location
 ) -> tuple[R, dict[str, Any]] | None:
     for route, pattern in compiled_routes:
         params = pattern.match(location.pathname)
diff --git a/idom_router/routers/__init__.py b/idom_router/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils.py b/idom_router/routers/regex.py
similarity index 86%
rename from tests/utils.py
rename to idom_router/routers/regex.py
index d0fdc9f..567215c 100644
--- a/tests/utils.py
+++ b/idom_router/routers/regex.py
@@ -1,19 +1,20 @@
 from __future__ import annotations
 
 import re
+from uuid import UUID
 from typing import Any, Callable
 
 from idom_router import Route
 
 
-def compile_simple_regex_route(route: Route) -> RegexRoutePattern:
+def compile_regex_route(route: Route) -> RegexRoutePattern:
     """Compile simple regex route.
 
     Named regex groups can end with a `__type` suffix to specify a type converter
 
     For example, `(?P<id__int>[0-9]+)` will convert the `id` parameter to an `int`.
 
-    Supported types are `int`, `float`, and `list` where `list` will split on `,`.
+    Supported types are `int`, `float`, and `uuid`.
     """
     pattern = re.compile(route.path)
     return RegexRoutePattern(pattern)
@@ -41,7 +42,7 @@ def match(self, path: str) -> dict[str, str] | None:
 CONVERTERS: dict[str, Callable[[str], Any]] = {
     "int": int,
     "float": float,
-    "list": lambda s: s.split(","),
+    "uuid": UUID,
 }
 
 
diff --git a/idom_router/compilers.py b/idom_router/routers/starlette.py
similarity index 66%
rename from idom_router/compilers.py
rename to idom_router/routers/starlette.py
index 76d5b52..8668afd 100644
--- a/idom_router/compilers.py
+++ b/idom_router/routers/starlette.py
@@ -4,17 +4,18 @@
 from typing import Any
 
 from starlette.convertors import Convertor
-from starlette.routing import compile_path as _compile_starlette_path
+from starlette.routing import compile_path
 
 from idom_router.types import Route
+from idom_router.core import create_router
 
 
-def compile_starlette_route(route: Route) -> StarletteRoutePattern:
-    pattern, _, converters = _compile_starlette_path(route.path)
-    return StarletteRoutePattern(pattern, converters)
+def compile_starlette_route(route: Route) -> StarletteRouteResolver:
+    pattern, _, converters = compile_path(route.path)
+    return StarletteRouteResolver(pattern, converters)
 
 
-class StarletteRoutePattern:
+class StarletteRouteResolver:
     def __init__(
         self,
         pattern: re.Pattern[str],
@@ -32,3 +33,6 @@ def match(self, path: str) -> dict[str, Any] | None:
                 for k, v in match.groupdict().items()
             }
         return None
+
+
+starlette_router = create_router(compile_starlette_route)
diff --git a/idom_router/types.py b/idom_router/types.py
index a86c45e..813a5df 100644
--- a/idom_router/types.py
+++ b/idom_router/types.py
@@ -3,7 +3,7 @@
 from dataclasses import dataclass
 from typing import Any, Sequence, TypeVar
 
-from idom.types import Key
+from idom.types import Key, ComponentType
 from typing_extensions import Protocol, Self
 
 
@@ -26,18 +26,25 @@ def __init__(
         self.routes = (*routes_, *routes)
 
 
+class Router(Protocol):
+    def __call__(self, *routes: Route) -> ComponentType:
+        """Return a component that renders the first matching route"""
+
+
 R = TypeVar("R", bound=Route, contravariant=True)
 
 
 class RouteCompiler(Protocol[R]):
-    def __call__(self, route: R) -> RoutePattern:
-        """Compile a route into a pattern that can be matched against a path"""
+    def __call__(self, route: R) -> RouteResolver:
+        """Compile a route into a resolver that can be matched against a path"""
+
 
+class RouteResolver(Protocol):
+    """A compiled route that can be matched against a path"""
 
-class RoutePattern(Protocol):
     @property
     def key(self) -> Key:
-        """Uniquely identified this pattern"""
+        """Uniquely identified this resolver"""
 
-    def match(self, path: str) -> dict[str, Any] | None:
-        """Returns otherwise a dict of path parameters if the path matches, else None"""
+    def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
+        """Return the path's associated element and path params or None"""

From 2f8f853d751b4389701b94f91cb79b2e4c7bd09f Mon Sep 17 00:00:00 2001
From: rmorshea <ryan.morshead@gmail.com>
Date: Wed, 10 May 2023 00:30:28 -0600
Subject: [PATCH 5/6] remove starlette as dep

---
 idom_router/__init__.py                | 14 +++--
 idom_router/core.py                    | 46 ++++++--------
 idom_router/routers/__init__.py        |  0
 idom_router/routers/regex.py           | 50 ---------------
 idom_router/routers/starlette.py       | 38 ------------
 idom_router/simple.py                  | 86 ++++++++++++++++++++++++++
 idom_router/types.py                   | 34 +++++-----
 requirements/pkg-deps.txt              |  1 -
 tests/{test_router.py => test_core.py} | 77 ++++++-----------------
 tests/test_simple.py                   | 50 +++++++++++++++
 10 files changed, 195 insertions(+), 201 deletions(-)
 delete mode 100644 idom_router/routers/__init__.py
 delete mode 100644 idom_router/routers/regex.py
 delete mode 100644 idom_router/routers/starlette.py
 create mode 100644 idom_router/simple.py
 rename tests/{test_router.py => test_core.py} (64%)
 create mode 100644 tests/test_simple.py

diff --git a/idom_router/__init__.py b/idom_router/__init__.py
index 7ef08c0..8d0c697 100644
--- a/idom_router/__init__.py
+++ b/idom_router/__init__.py
@@ -1,18 +1,20 @@
 # the version is statically loaded by setup.py
 __version__ = "0.0.1"
 
-from idom_router.types import Route, RouteCompiler, RouteResolver
+from . import simple
+from .core import create_router, link, route, router_component, use_params, use_query
+from .types import Route, RouteCompiler, RouteResolver
 
-from .core import link, create_router, router_component, use_params, use_query
-
-__all__ = [
+__all__ = (
     "create_router",
     "link",
+    "route",
+    "route",
     "Route",
     "RouteCompiler",
     "router_component",
     "RouteResolver",
-    "use_location",
+    "simple",
     "use_params",
     "use_query",
-]
+)
diff --git a/idom_router/core.py b/idom_router/core.py
index 73b32a9..408c922 100644
--- a/idom_router/core.py
+++ b/idom_router/core.py
@@ -19,11 +19,15 @@
 from idom.types import ComponentType, Context, Location
 from idom.web.module import export, module_from_file
 
-from idom_router.types import Route, RouteCompiler, RouteResolver, Router
+from idom_router.types import Route, RouteCompiler, Router, RouteResolver
 
 R = TypeVar("R", bound=Route)
 
 
+def route(path: str, element: Any | None, *routes: Route) -> Route:
+    return Route(path, element, routes)
+
+
 def create_router(compiler: RouteCompiler[R]) -> Router[R]:
     """A decorator that turns a route compiler into a router"""
 
@@ -40,25 +44,19 @@ def router_component(
 ) -> ComponentType | None:
     old_conn = use_connection()
     location, set_location = use_state(old_conn.location)
-    router_state = use_context(_route_state_context)
-
 
-    if router_state is not None:
-        raise RuntimeError("Another router is already active in this context")
+    resolvers = use_memo(
+        lambda: tuple(map(compiler, _iter_routes(routes))),
+        dependencies=(compiler, hash(routes)),
+    )
 
-    # Memoize the compiled routes and the match separately so that we don't
-    # recompile the routes on renders where only the location has changed
-    compiled_routes = use_memo(lambda: _compile_routes(routes, compiler))
-    match = use_memo(lambda: _match_route(compiled_routes, location))
+    match = use_memo(lambda: _match_route(resolvers, location))
 
     if match is not None:
-        route, params = match
+        element, params = match
         return ConnectionContext(
-            _route_state_context(
-                route.element, value=_RouteState(set_location, params)
-            ),
+            _route_state_context(element, value=_RouteState(set_location, params)),
             value=Connection(old_conn.scope, location, old_conn.carrier),
-            key=route.path,
         )
 
     return None
@@ -98,26 +96,20 @@ def use_query(
     )
 
 
-def _compile_routes(
-    routes: Sequence[R], compiler: RouteCompiler[R]
-) -> list[tuple[Any, RouteResolver]]:
-    return [(r, compiler(r)) for r in _iter_routes(routes)]
-
-
 def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
     for parent in routes:
         for child in _iter_routes(parent.routes):
-            yield replace(child, path=parent.path + child.path)
+            yield replace(child, path=parent.path + child.path)  # type: ignore[misc]
         yield parent
 
 
 def _match_route(
-    compiled_routes: list[tuple[R, RouteResolver]], location: Location
-) -> tuple[R, dict[str, Any]] | None:
-    for route, pattern in compiled_routes:
-        params = pattern.match(location.pathname)
-        if params is not None:  # explicitely None check (could be empty dict)
-            return route, params
+    compiled_routes: Sequence[RouteResolver], location: Location
+) -> tuple[Any, dict[str, Any]] | None:
+    for resolver in compiled_routes:
+        match = resolver.resolve(location.pathname)
+        if match is not None:
+            return match
     return None
 
 
diff --git a/idom_router/routers/__init__.py b/idom_router/routers/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/idom_router/routers/regex.py b/idom_router/routers/regex.py
deleted file mode 100644
index 567215c..0000000
--- a/idom_router/routers/regex.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from __future__ import annotations
-
-import re
-from uuid import UUID
-from typing import Any, Callable
-
-from idom_router import Route
-
-
-def compile_regex_route(route: Route) -> RegexRoutePattern:
-    """Compile simple regex route.
-
-    Named regex groups can end with a `__type` suffix to specify a type converter
-
-    For example, `(?P<id__int>[0-9]+)` will convert the `id` parameter to an `int`.
-
-    Supported types are `int`, `float`, and `uuid`.
-    """
-    pattern = re.compile(route.path)
-    return RegexRoutePattern(pattern)
-
-
-class RegexRoutePattern:
-    def __init__(self, pattern: re.Pattern) -> None:
-        self.pattern = pattern
-        self.key = pattern.pattern
-
-    def match(self, path: str) -> dict[str, str] | None:
-        match = self.pattern.match(path)
-        if match:
-            params: dict[str, Any] = {}
-            for k, v in match.groupdict().items():
-                name, _, type_ = k.partition("__")
-                try:
-                    params[name] = CONVERTERS.get(type_, DEFAULT_CONVERTER)(v)
-                except ValueError:
-                    return None
-            return params
-        return None
-
-
-CONVERTERS: dict[str, Callable[[str], Any]] = {
-    "int": int,
-    "float": float,
-    "uuid": UUID,
-}
-
-
-def DEFAULT_CONVERTER(s: str) -> str:
-    return s
diff --git a/idom_router/routers/starlette.py b/idom_router/routers/starlette.py
deleted file mode 100644
index 8668afd..0000000
--- a/idom_router/routers/starlette.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from __future__ import annotations
-
-import re
-from typing import Any
-
-from starlette.convertors import Convertor
-from starlette.routing import compile_path
-
-from idom_router.types import Route
-from idom_router.core import create_router
-
-
-def compile_starlette_route(route: Route) -> StarletteRouteResolver:
-    pattern, _, converters = compile_path(route.path)
-    return StarletteRouteResolver(pattern, converters)
-
-
-class StarletteRouteResolver:
-    def __init__(
-        self,
-        pattern: re.Pattern[str],
-        converters: dict[str, Convertor],
-    ) -> None:
-        self.pattern = pattern
-        self.key = pattern.pattern
-        self.converters = converters
-
-    def match(self, path: str) -> dict[str, Any] | None:
-        match = self.pattern.match(path)
-        if match:
-            return {
-                k: self.converters[k].convert(v) if k in self.converters else v
-                for k, v in match.groupdict().items()
-            }
-        return None
-
-
-starlette_router = create_router(compile_starlette_route)
diff --git a/idom_router/simple.py b/idom_router/simple.py
new file mode 100644
index 0000000..c61a0b0
--- /dev/null
+++ b/idom_router/simple.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import re
+import uuid
+from typing import Any, Callable
+
+from typing_extensions import TypeAlias, TypedDict
+
+from idom_router.core import create_router
+from idom_router.types import Route
+
+__all__ = ["router"]
+
+ConversionFunc: TypeAlias = "Callable[[str], Any]"
+ConverterMapping: TypeAlias = "dict[str, ConversionFunc]"
+
+PARAM_REGEX = re.compile(r"{(?P<name>\w+)(?P<type>:\w+)?}")
+
+
+class SimpleResolver:
+    def __init__(self, route: Route) -> None:
+        self.element = route.element
+        self.pattern, self.converters = parse_path(route.path)
+        self.key = self.pattern.pattern
+
+    def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
+        print(path)
+        print(self.key)
+        match = self.pattern.match(path)
+        if match:
+            return (
+                self.element,
+                {k: self.converters[k](v) for k, v in match.groupdict().items()},
+            )
+        return None
+
+
+def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]:
+    pattern = "^"
+    last_match_end = 0
+    converters: ConverterMapping = {}
+    for match in PARAM_REGEX.finditer(path):
+        param_name = match.group("name")
+        param_type = (match.group("type") or "str").lstrip(":")
+        try:
+            param_conv = CONVERSION_TYPES[param_type]
+        except KeyError:
+            raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}")
+        pattern += re.escape(path[last_match_end : match.start()])
+        pattern += f"(?P<{param_name}>{param_conv['regex']})"
+        converters[param_name] = param_conv["func"]
+        last_match_end = match.end()
+    pattern += re.escape(path[last_match_end:]) + "$"
+    return re.compile(pattern), converters
+
+
+class ConversionInfo(TypedDict):
+    regex: str
+    func: ConversionFunc
+
+
+CONVERSION_TYPES: dict[str, ConversionInfo] = {
+    "str": {
+        "regex": r"[^/]+",
+        "func": str,
+    },
+    "int": {
+        "regex": r"\d+",
+        "func": int,
+    },
+    "float": {
+        "regex": r"\d+(\.\d+)?",
+        "func": float,
+    },
+    "uuid": {
+        "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
+        "func": uuid.UUID,
+    },
+    "path": {
+        "regex": r".+",
+        "func": str,
+    },
+}
+
+
+router = create_router(SimpleResolver)
diff --git a/idom_router/types.py b/idom_router/types.py
index 813a5df..092b734 100644
--- a/idom_router/types.py
+++ b/idom_router/types.py
@@ -1,39 +1,33 @@
 from __future__ import annotations
 
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Any, Sequence, TypeVar
 
-from idom.types import Key, ComponentType
+from idom.core.vdom import is_vdom
+from idom.types import ComponentType, Key
 from typing_extensions import Protocol, Self
 
 
-@dataclass
+@dataclass(frozen=True)
 class Route:
     path: str
-    element: Any
+    element: Any = field(hash=False)
     routes: Sequence[Self]
 
-    def __init__(
-        self,
-        path: str,
-        element: Any | None,
-        *routes_: Self,
-        # we need kwarg in order to play nice with the expected dataclass interface
-        routes: Sequence[Self] = (),
-    ) -> None:
-        self.path = path
-        self.element = element
-        self.routes = (*routes_, *routes)
-
-
-class Router(Protocol):
-    def __call__(self, *routes: Route) -> ComponentType:
-        """Return a component that renders the first matching route"""
+    def __hash__(self) -> int:
+        el = self.element
+        key = el["key"] if is_vdom(el) and "key" in el else getattr(el, "key", id(el))
+        return hash((self.path, key, self.routes))
 
 
 R = TypeVar("R", bound=Route, contravariant=True)
 
 
+class Router(Protocol[R]):
+    def __call__(self, *routes: R) -> ComponentType:
+        """Return a component that renders the first matching route"""
+
+
 class RouteCompiler(Protocol[R]):
     def __call__(self, route: R) -> RouteResolver:
         """Compile a route into a resolver that can be matched against a path"""
diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt
index c205c19..002aef5 100644
--- a/requirements/pkg-deps.txt
+++ b/requirements/pkg-deps.txt
@@ -1,3 +1,2 @@
 idom >=1
 typing_extensions
-starlette
diff --git a/tests/test_router.py b/tests/test_core.py
similarity index 64%
rename from tests/test_router.py
rename to tests/test_core.py
index ce05220..f8c390c 100644
--- a/tests/test_router.py
+++ b/tests/test_core.py
@@ -1,8 +1,7 @@
 from idom import Ref, component, html, use_location
 from idom.testing import DisplayFixture
 
-from idom_router import Route, link, router, use_params, use_query
-from tests.utils import compile_simple_regex_route
+from idom_router import link, route, simple, use_params, use_query
 
 
 async def test_simple_router(display: DisplayFixture):
@@ -14,11 +13,11 @@ def check_location():
             assert use_location().pathname == path
             return html.h1({"id": name}, path)
 
-        return Route(path, check_location(), *routes)
+        return route(path, check_location(), *routes)
 
     @component
     def sample():
-        return router(
+        return simple.router(
             make_location_check("/a"),
             make_location_check("/b"),
             make_location_check("/c"),
@@ -49,14 +48,14 @@ def sample():
 async def test_nested_routes(display: DisplayFixture):
     @component
     def sample():
-        return router(
-            Route(
+        return simple.router(
+            route(
                 "/a",
                 html.h1({"id": "a"}, "A"),
-                Route(
+                route(
                     "/b",
                     html.h1({"id": "b"}, "B"),
-                    Route("/c", html.h1({"id": "c"}, "C")),
+                    route("/c", html.h1({"id": "c"}, "C")),
                 ),
             ),
         )
@@ -78,12 +77,12 @@ async def test_navigate_with_link(display: DisplayFixture):
     @component
     def sample():
         render_count.current += 1
-        return router(
-            Route("/", link("Root", to="/a", id="root")),
-            Route("/a", link("A", to="/b", id="a")),
-            Route("/b", link("B", to="/c", id="b")),
-            Route("/c", link("C", to="/default", id="c")),
-            Route("/{path:path}", html.h1({"id": "default"}, "Default")),
+        return simple.router(
+            route("/", link("Root", to="/a", id="root")),
+            route("/a", link("A", to="/b", id="a")),
+            route("/b", link("B", to="/c", id="b")),
+            route("/c", link("C", to="/default", id="c")),
+            route("/{path:path}", html.h1({"id": "default"}, "Default")),
         )
 
     await display.show(sample)
@@ -109,14 +108,14 @@ def check_params():
 
     @component
     def sample():
-        return router(
-            Route(
+        return simple.router(
+            route(
                 "/first/{first:str}",
                 check_params(),
-                Route(
+                route(
                     "/second/{second:str}",
                     check_params(),
-                    Route(
+                    route(
                         "/third/{third:str}",
                         check_params(),
                     ),
@@ -145,50 +144,10 @@ def check_query():
 
     @component
     def sample():
-        return router(Route("/", check_query()))
+        return simple.router(route("/", check_query()))
 
     await display.show(sample)
 
     expected_query = {"hello": ["world"], "thing": ["1", "2"]}
     await display.goto("?hello=world&thing=1&thing=2")
     await display.page.wait_for_selector("#success")
-
-
-async def test_custom_path_compiler(display: DisplayFixture):
-    expected_params = {}
-
-    @component
-    def check_params():
-        assert use_params() == expected_params
-        return html.h1({"id": "success"}, "success")
-
-    @component
-    def sample():
-        return router(
-            Route(
-                r"/first/(?P<first__int>\d+)",
-                check_params(),
-                Route(
-                    r"/second/(?P<second__float>[\d\.]+)",
-                    check_params(),
-                    Route(
-                        r"/third/(?P<third__list>[\d,]+)",
-                        check_params(),
-                    ),
-                ),
-            ),
-            compiler=compile_simple_regex_route,
-        )
-
-    await display.show(sample)
-
-    for path, expected_params in [
-        ("/first/1", {"first": 1}),
-        ("/first/1/second/2.1", {"first": 1, "second": 2.1}),
-        (
-            "/first/1/second/2.1/third/3,3",
-            {"first": 1, "second": 2.1, "third": ["3", "3"]},
-        ),
-    ]:
-        await display.goto(path)
-        await display.page.wait_for_selector("#success")
diff --git a/tests/test_simple.py b/tests/test_simple.py
new file mode 100644
index 0000000..2047eae
--- /dev/null
+++ b/tests/test_simple.py
@@ -0,0 +1,50 @@
+import re
+import uuid
+
+import pytest
+
+from idom_router.simple import parse_path
+
+
+def test_parse_path():
+    assert parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {})
+    assert parse_path("/a/{b}/c") == (
+        re.compile(r"^/a/(?P<b>[^/]+)/c$"),
+        {"b": str},
+    )
+    assert parse_path("/a/{b:int}/c") == (
+        re.compile(r"^/a/(?P<b>\d+)/c$"),
+        {"b": int},
+    )
+    assert parse_path("/a/{b:int}/{c:float}/c") == (
+        re.compile(r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/c$"),
+        {"b": int, "c": float},
+    )
+    assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == (
+        re.compile(
+            r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
+            r"0-9a-f]{4}-[0-9a-f]{12})/c$"
+        ),
+        {"b": int, "c": float, "d": uuid.UUID},
+    )
+    assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == (
+        re.compile(
+            r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
+            r"0-9a-f]{4}-[0-9a-f]{12})/(?P<e>.+)/c$"
+        ),
+        {"b": int, "c": float, "d": uuid.UUID, "e": str},
+    )
+
+
+def test_parse_path_unkown_conversion():
+    with pytest.raises(ValueError):
+        parse_path("/a/{b:unknown}/c")
+
+
+def test_parse_path_re_escape():
+    """Check that we escape regex characters in the path"""
+    assert parse_path("/a/{b:int}/c.d") == (
+        #                          ^ regex character
+        re.compile(r"^/a/(?P<b>\d+)/c\.d$"),
+        {"b": int},
+    )

From a169d6f4ef436cf530cd764c460bf15a0efc24c7 Mon Sep 17 00:00:00 2001
From: rmorshea <ryan.morshead@gmail.com>
Date: Wed, 10 May 2023 21:43:29 -0600
Subject: [PATCH 6/6] remove print

---
 idom_router/simple.py        | 2 --
 requirements/check-style.txt | 1 +
 setup.py                     | 2 +-
 3 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/idom_router/simple.py b/idom_router/simple.py
index c61a0b0..7c6901e 100644
--- a/idom_router/simple.py
+++ b/idom_router/simple.py
@@ -24,8 +24,6 @@ def __init__(self, route: Route) -> None:
         self.key = self.pattern.pattern
 
     def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
-        print(path)
-        print(self.key)
         match = self.pattern.match(path)
         if match:
             return (
diff --git a/requirements/check-style.txt b/requirements/check-style.txt
index ea105f4..e8e2fb4 100644
--- a/requirements/check-style.txt
+++ b/requirements/check-style.txt
@@ -1,4 +1,5 @@
 black
 flake8
+flake8-print
 flake8_idom_hooks
 isort
diff --git a/setup.py b/setup.py
index dc0fefa..cde7673 100644
--- a/setup.py
+++ b/setup.py
@@ -71,7 +71,7 @@
             package["version"] = eval(line.split("=", 1)[1])
             break
     else:
-        print("No version found in %s/__init__.py" % package_dir)
+        print("No version found in %s/__init__.py" % package_dir)  # noqa: T201
         sys.exit(1)