Repository: leptos-rs/leptos
Pull request #2217 (2024-01-21 21:39:17)

Trailing slashes (#2154) take 2
Fixes regression in #2172. The problem was that it would count 1 path segment in root path ("/") instead of zero. Just moved root path special case to segment extractor function.

Router example tests now pass, also made sure router tests pass (at least with "ssr" feature). Might have missed smth else.

skirsdeda: There are still differences in path matching corner-cases. In router example '//settings' or '/settings/' or '//settings/' (actual path entered in browser) no longer match settings page but it works fine in previous implementation.
gbj: Thanks for updating this! I'm leaving a comment here just to note that I'll need to set aside some time to do a more in depth review, hopefully in the near future. I am going to be doing a pretty large router rewrite for 0.7, and will build this into it in any case. I'll try to get to this some time soon, but in the meantime it is good to be able to point people to this branch as a solution for the particular situation. I'm grateful for your work.
gbj: @skirsdeda I have reviewed this and tested it out in some depth, including against the examples that caused broken behavior last time, and it looks to me like this is right. I agree with zoomiti's comments that if the trailing slash props implement `Default`, they don't need the `Option<_>`. 

If you address that then I think this should be ready to merge.
skirsdeda: @gbj `Route` components can inherit trailing slash behavior from parent routes, that's the reason for having `Option<_>` there. However, `Router` doesn't need `Option<_>` since it cannot be nested, so I can fix that.

Patch:
diff --git a/.github/workflows/get-changed-examples-matrix.yml b/.github/workflows/get-changed-examples-matrix.yml
index 529ce8d3..bcbf7085 100644
--- a/.github/workflows/get-changed-examples-matrix.yml
+++ b/.github/workflows/get-changed-examples-matrix.yml
@@ -34,7 +34,6 @@ jobs:
             examples/**
             !examples/cargo-make/**
             !examples/gtk/**
-            !examples/hackernews_js_fetch/**
             !examples/Makefile.toml
             !examples/*.md
           json: true
diff --git a/.github/workflows/run-cargo-make-task.yml b/.github/workflows/run-cargo-make-task.yml
index 8aa6c879..81827450 100644
--- a/.github/workflows/run-cargo-make-task.yml
+++ b/.github/workflows/run-cargo-make-task.yml
@@ -107,6 +107,11 @@ jobs:
             fi
           done
 
+      - name: Install Deno
+        uses: denoland/setup-deno@v1
+        with:
+          deno-version: v1.x
+
       # Run Cargo Make Task
       - name: ${{ inputs.cargo_make_task }}
         run: |
diff --git a/Cargo.toml b/Cargo.toml
index cb10f4cd..e0a33375 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,7 +25,7 @@ members = [
 exclude = ["benchmarks", "examples"]
 
 [workspace.package]
-version = "0.6.5"
+version = "0.6.6"
 
 [workspace.dependencies]
 leptos = { path = "./leptos", version = "0.6.5" }
diff --git a/examples/cargo-make/client-process.toml b/examples/cargo-make/client-process.toml
index 3eb8554a..90ee511f 100644
--- a/examples/cargo-make/client-process.toml
+++ b/examples/cargo-make/client-process.toml
@@ -3,32 +3,36 @@
 [tasks.stop-client]
 condition = { env_set = ["CLIENT_PROCESS_NAME"] }
 script = '''
-  if [ ! -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
+  if pidof -q ${CLIENT_PROCESS_NAME}; then
+    echo "  Stopping ${CLIENT_PROCESS_NAME}"
     pkill -ef ${CLIENT_PROCESS_NAME}
+  else
+    echo "  ${CLIENT_PROCESS_NAME} is already stopped"
   fi
 '''
 
 [tasks.client-status]
 condition = { env_set = ["CLIENT_PROCESS_NAME"] }
 script = '''
-  if [ -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
-    echo "  ${CLIENT_PROCESS_NAME} is not running"
-  else
+  if pidof -q ${CLIENT_PROCESS_NAME}; then
     echo "  ${CLIENT_PROCESS_NAME} is up"
+  else
+    echo "  ${CLIENT_PROCESS_NAME} is not running"
   fi
 '''
 
 [tasks.maybe-start-client]
 condition = { env_set = ["CLIENT_PROCESS_NAME"] }
 script = '''
-  if [ -z $(pidof ${CLIENT_PROCESS_NAME}) ]; then
+  if pidof -q ${CLIENT_PROCESS_NAME}; then
+    echo "  ${CLIENT_PROCESS_NAME} is already started"
+  else
     echo "  Starting ${CLIENT_PROCESS_NAME}"
-    if [ -z ${SPAWN_CLIENT_PROCESS} ];then
+    if [ -n "${SPAWN_CLIENT_PROCESS}" ];then
+      echo "Spawning process..."
       cargo make start-client ${@} &
     else
       cargo make start-client ${@}
     fi
-  else
-    echo "  ${CLIENT_PROCESS_NAME} is already started"
   fi
 '''
diff --git a/examples/cargo-make/deno-build.toml b/examples/cargo-make/deno-build.toml
new file mode 100644
index 00000000..30b5f4da
--- /dev/null
+++ b/examples/cargo-make/deno-build.toml
@@ -0,0 +1,24 @@
+[tasks.build]
+clear = true
+command = "deno"
+args = ["task", "build"]
+
+[tasks.start-client]
+command = "deno"
+args = ["task", "start"]
+
+[tasks.check]
+clear = true
+dependencies = ["check-debug", "check-release"]
+
+[tasks.check-debug]
+toolchain = "nightly-2024-01-29"
+command = "cargo"
+args = ["check-all-features"]
+install_crate = "cargo-all-features"
+
+[tasks.check-release]
+toolchain = "nightly-2024-01-29"
+command = "cargo"
+args = ["check-all-features", "--release"]
+install_crate = "cargo-all-features"
diff --git a/examples/cargo-make/scripts/web-report.sh b/examples/cargo-make/scripts/web-report.sh
index 9359f159..591b7905 100755
--- a/examples/cargo-make/scripts/web-report.sh
+++ b/examples/cargo-make/scripts/web-report.sh
@@ -5,7 +5,7 @@ set -emu
 BOLD="\e[1m"
 ITALIC="\e[3m"
 YELLOW="\e[1;33m"
-RED="\e[1;36m"
+BLUE="\e[1;36m"
 RESET="\e[0m"
 
 function web { #task: only include examples with web cargo-make configuration
@@ -87,28 +87,34 @@ function print_crate_tags {
             *"cargo-make/cargo-leptos.toml"*)
                 crate_tags=$crate_tags"L"
                 ;;
+            *"cargo-make/deno-build.toml"*)
+                crate_tags=$crate_tags"D"
+                ;;
             esac
         done <"./Makefile.toml"
 
         # Sort tags
-        local sorted_crate_symbols
-        sorted_crate_symbols=$(echo "$crate_tags" | grep -o . | sort | tr -d "\n")
+        local keys
+        keys=$(echo "$crate_tags" | grep -o . | sort | tr -d "\n")
 
         # Find leptos projects that are not configured to build with cargo-leptos
-        sorted_crate_symbols=${sorted_crate_symbols//"LM"/"L"}
+        keys=${keys//"LM"/"L"}
+
+        # Find leptos projects that are not configured to build with deno
+        keys=${keys//"DM"/"D"}
 
         # Maybe print line
         local crate_line=$path
 
         if [ -n "$crate_tags" ]; then
             local color=$YELLOW
-            case $sorted_crate_symbols in
+            case $keys in
             *"M"*)
-                color=$RED
+                color=$BLUE
                 ;;
             esac
 
-            crate_line="$crate_line ➤ ${color}$sorted_crate_symbols${RESET}"
+            crate_line="$crate_line ➤ ${color}$keys${RESET}"
             echo -e "$crate_line"
         elif [ "$#" -gt 0 ]; then
             crate_line="${BOLD}$crate_line${RESET}"
@@ -129,16 +135,17 @@ function find_makefile_lines {
 
 function print_footer {
     c="${BOLD}${YELLOW}C${RESET} = Cucumber Test Runner"
-    d="${BOLD}${YELLOW}F${RESET} = Fantoccini WebDriver"
+    d="${BOLD}${YELLOW}D${RESET} = Deno"
+    f="${BOLD}${YELLOW}F${RESET} = Fantoccini WebDriver"
     l="${BOLD}${YELLOW}L${RESET} = Cargo Leptos"
-    m="${BOLD}${RED}M${RESET} = Cargo Leptos Metadata Only (${ITALIC}ci is not configured to build with cargo-leptos${RESET})"
+    m="${BOLD}${BLUE}M${RESET} = Cargo Leptos Metadata Only (${ITALIC}ci is not configured to build with cargo-leptos or deno${RESET})"
     n="${BOLD}${YELLOW}N${RESET} = Node"
     p="${BOLD}${YELLOW}P${RESET} = Playwright Test"
     t="${BOLD}${YELLOW}T${RESET} = Trunk"
     w="${BOLD}${YELLOW}W${RESET} = WASM Test"
 
     echo
-    echo -e "${ITALIC}Report Keys:${RESET}\n $c\n $d\n $l\n $m\n $n\n $p\n $t\n $w"
+    echo -e "${ITALIC}Report Keys:${RESET}\n $c\n $d\n $f\n $l\n $m\n $n\n $p\n $t\n $w"
     echo
 }
 
diff --git a/examples/cargo-make/server-process.toml b/examples/cargo-make/server-process.toml
index d4e323d7..eaae1983 100644
--- a/examples/cargo-make/server-process.toml
+++ b/examples/cargo-make/server-process.toml
@@ -3,18 +3,21 @@
 [tasks.stop-server]
 condition = { env_set = ["SERVER_PROCESS_NAME"] }
 script = '''
-  if [ ! -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
+  if pidof -q ${SERVER_PROCESS_NAME}; then
+    echo "  Stopping ${SERVER_PROCESS_NAME}"
     pkill -ef ${SERVER_PROCESS_NAME}
+  else
+    echo "  ${SERVER_PROCESS_NAME} is already stopped"
   fi
 '''
 
 [tasks.server-status]
 condition = { env_set = ["SERVER_PROCESS_NAME"] }
 script = '''
-  if [ -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
-    echo "  ${SERVER_PROCESS_NAME} is not running"
-  else
+  if pidof -q ${SERVER_PROCESS_NAME}; then
     echo "  ${SERVER_PROCESS_NAME} is up"
+  else
+    echo "  ${SERVER_PROCESS_NAME} is not running"
   fi
 '''
 
@@ -24,11 +27,11 @@ script = '''
   YELLOW="\e[0;33m"
   RESET="\e[0m"
 
-  if [ -z $(pidof ${SERVER_PROCESS_NAME}) ]; then
+  if pidof -q ${SERVER_PROCESS_NAME}; then
+    echo "  ${SERVER_PROCESS_NAME} is already started"
+  else
     echo "  Starting ${SERVER_PROCESS_NAME}"
     echo "  ${YELLOW}>> Run cargo make stop to end process${RESET}"
     cargo make start-server ${@} & 
-  else
-    echo "  ${SERVER_PROCESS_NAME} is already started"
   fi
 '''
diff --git a/examples/cargo-make/webdriver.toml b/examples/cargo-make/webdriver.toml
index 57d155c8..b5e4370f 100644
--- a/examples/cargo-make/webdriver.toml
+++ b/examples/cargo-make/webdriver.toml
@@ -6,25 +6,33 @@ script = '''
   RESET="\e[0m"
 
   if command -v chromedriver; then
-    if [ -z $(pidof chromedriver) ]; then
+    if pidof -q chromedriver; then
+      echo "  chromedriver is already started"
+    else
+      echo "Starting chomedriver"
       chromedriver --port=4444 &
     fi
   else
-    echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
+    echo "${RED}${BOLD}ERROR${RESET} - chromedriver not found"
     exit 1
   fi
 '''
 
 [tasks.stop-webdriver]
 script = '''
-  pkill -f "chromedriver"
+  if pidof -q chromedriver; then
+    echo "  Stopping chromedriver"
+    pkill -ef "chromedriver"
+  else
+    echo "  chromedriver is already stopped"
+  fi
 '''
 
 [tasks.webdriver-status]
 script = '''
-  if [ -z $(pidof chromedriver) ]; then
-    echo chromedriver is not running
-  else
+  if pidof -q chromedriver; then
     echo chromedriver is up
+  else
+    echo chromedriver is not running
   fi
 '''
diff --git a/examples/gtk/Makefile.toml b/examples/gtk/Makefile.toml
deleted file mode 100644
index d8bf0a68..00000000
--- a/examples/gtk/Makefile.toml
+++ /dev/null
@@ -1,8 +0,0 @@
-[env]
-VERIFY_GTK = false
-
-[tasks.verify-flow]
-condition = { env_set = ["VERIFY_GTK"] }
-
-[tasks.verify]
-condition = { env_set = ["VERIFY_GTK"] }
diff --git a/examples/hackernews_js_fetch/Makefile.toml b/examples/hackernews_js_fetch/Makefile.toml
index 6fe53288..6d2dcf0b 100644
--- a/examples/hackernews_js_fetch/Makefile.toml
+++ b/examples/hackernews_js_fetch/Makefile.toml
@@ -1,1 +1,8 @@
-extend = [{ path = "../cargo-make/main.toml" }]
+extend = [
+    { path = "../cargo-make/main.toml" },
+    { path = "../cargo-make/deno-build.toml" },
+]
+
+[env]
+
+CLIENT_PROCESS_NAME = "deno"
diff --git a/examples/ssr_modes/src/app.rs b/examples/ssr_modes/src/app.rs
index 8d167d4f..9da69a21 100644
--- a/examples/ssr_modes/src/app.rs
+++ b/examples/ssr_modes/src/app.rs
@@ -9,12 +9,13 @@ use thiserror::Error;
 pub fn App() -> impl IntoView {
     // Provides context that manages stylesheets, titles, meta tags, etc.
     provide_meta_context();
+    let fallback = || view! { "Page not found." }.into_view();
 
     view! {
         <Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
         <Title text="Welcome to Leptos"/>
 
-        <Router>
+        <Router fallback>
             <main>
                 <Routes>
                     // We’ll load the home page with out-of-order streaming and <Suspense/>
diff --git a/examples/ssr_modes_axum/src/app.rs b/examples/ssr_modes_axum/src/app.rs
index 45923754..6b24570f 100644
--- a/examples/ssr_modes_axum/src/app.rs
+++ b/examples/ssr_modes_axum/src/app.rs
@@ -9,12 +9,13 @@ use thiserror::Error;
 pub fn App() -> impl IntoView {
     // Provides context that manages stylesheets, titles, meta tags, etc.
     provide_meta_context();
+    let fallback = || view! { "Page not found." }.into_view();
 
     view! {
         <Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
         <Title text="Welcome to Leptos"/>
 
-        <Router>
+        <Router fallback>
             <main>
                 <Routes>
                     // We’ll load the home page with out-of-order streaming and <Suspense/>
diff --git a/integrations/actix/src/lib.rs b/integrations/actix/src/lib.rs
index 9f7d1900..0907b5ca 100644
--- a/integrations/actix/src/lib.rs
+++ b/integrations/actix/src/lib.rs
@@ -203,7 +203,7 @@ pub fn handle_server_fns() -> Route {
 /// context, allowing you to pass in info about the route or user from Actix, or other info.
 ///
 /// **NOTE**: If your server functions expect a context, make sure to provide it both in
-/// [`handle_server_fns_with_context`] **and** in [`leptos_routes_with_context`] (or whatever
+/// [`handle_server_fns_with_context`] **and** in [`LeptosRoutes::leptos_routes_with_context`] (or whatever
 /// rendering method you are using). During SSR, server functions are called by the rendering
 /// method, while subsequent calls from the client are handled by the server function handler.
 /// The same context needs to be provided to both handlers.
diff --git a/integrations/axum/src/lib.rs b/integrations/axum/src/lib.rs
index 0122c586..a64729b3 100644
--- a/integrations/axum/src/lib.rs
+++ b/integrations/axum/src/lib.rs
@@ -257,7 +257,13 @@ async fn handle_server_fns_inner(
 
     let (tx, rx) = futures::channel::oneshot::channel();
 
+    // capture current span to enable trace context propagation
+    let current_span = tracing::Span::current();
+
     spawn_task!(async move {
+        // enter captured span for trace context propagation in spawned task
+        let _guard = current_span.enter();
+
         let path = req.uri().path().to_string();
         let (req, parts) = generate_request_and_parts(req);
 
diff --git a/leptos/Cargo.toml b/leptos/Cargo.toml
index 2e718b88..ecbda31b 100644
--- a/leptos/Cargo.toml
+++ b/leptos/Cargo.toml
@@ -15,7 +15,7 @@ leptos_macro = { workspace = true }
 leptos_reactive = { workspace = true }
 leptos_server = { workspace = true }
 leptos_config = { workspace = true }
-leptos-spin-macro = { git = "https://github.com/fermyon/leptos-spin", optional = true}
+leptos-spin-macro = { version="0.1", optional = true}
 tracing = "0.1"
 typed-builder = "0.18"
 typed-builder-macro = "0.18"
@@ -91,7 +91,9 @@ denylist = [
   "rustls",
   "default-tls",
   "wasm-bindgen",
-  "trace-component-props"
+  "trace-component-props",
+  "spin",
+  "experimental-islands"
 ]
 skip_feature_sets = [
   [
diff --git a/leptos/src/suspense_component.rs b/leptos/src/suspense_component.rs
index 82b780b3..b0815765 100644
--- a/leptos/src/suspense_component.rs
+++ b/leptos/src/suspense_component.rs
@@ -36,7 +36,7 @@ use std::rc::Rc;
 ///   <div>
 ///     <Suspense fallback=move || view! { <p>"Loading (Suspense Fallback)..."</p> }>
 ///       {move || {
-///           cats.read().map(|data| match data {
+///           cats.get().map(|data| match data {
 ///             None => view! {  <pre>"Error"</pre> }.into_view(),
 ///             Some(cats) => cats
 ///                 .iter()
diff --git a/leptos_dom/src/helpers.rs b/leptos_dom/src/helpers.rs
index 3dbb269b..99fe8a4e 100644
--- a/leptos_dom/src/helpers.rs
+++ b/leptos_dom/src/helpers.rs
@@ -103,6 +103,25 @@ pub fn request_animation_frame(cb: impl FnOnce() + 'static) {
     _ = request_animation_frame_with_handle(cb);
 }
 
+// Closure::once_into_js only frees the callback when it's actually
+// called, so this instead uses into_js_value, which can be freed by
+// the host JS engine's GC if it supports weak references (which all
+// modern brower engines do).  The way this works is that the provided
+// callback's captured data is dropped immediately after being called,
+// as before, but it leaves behind a small stub closure rust-side that
+// will be freed "eventually" by the JS GC.  If the function is never
+// called (e.g., it's a cancelled timeout or animation frame callback)
+// then it will also be freed eventually.
+fn closure_once(cb: impl FnOnce() + 'static) -> JsValue {
+    let mut wrapped_cb: Option<Box<dyn FnOnce()>> = Some(Box::new(cb));
+    let closure = Closure::new(move || {
+        if let Some(cb) = wrapped_cb.take() {
+            cb()
+        }
+    });
+    closure.into_js_value()
+}
+
 /// Runs the given function between the next repaint using
 /// [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame),
 /// returning a cancelable handle.
@@ -128,7 +147,7 @@ pub fn request_animation_frame_with_handle(
             .map(AnimationFrameRequestHandle)
     }
 
-    raf(Closure::once_into_js(cb))
+    raf(closure_once(cb))
 }
 
 /// Handle that is generated by [request_idle_callback_with_handle] and can be
@@ -237,7 +256,7 @@ pub fn set_timeout_with_handle(
             .map(TimeoutHandle)
     }
 
-    st(Closure::once_into_js(cb), duration)
+    st(closure_once(cb), duration)
 }
 
 /// "Debounce" a callback function. This will cause it to wait for a period of `delay`
diff --git a/leptos_macro/src/lib.rs b/leptos_macro/src/lib.rs
index dc949643..abc6866f 100644
--- a/leptos_macro/src/lib.rs
+++ b/leptos_macro/src/lib.rs
@@ -870,6 +870,7 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
 /// - `name`: sets the identifier for the server function’s type, which is a struct created
 ///    to hold the arguments (defaults to the function identifier in PascalCase)
 /// - `prefix`: a prefix at which the server function handler will be mounted (defaults to `/api`)
+///    your prefix must begin with `/`. Otherwise your function won't be found.
 /// - `endpoint`: specifies the exact path at which the server function handler will be mounted,
 ///   relative to the prefix (defaults to the function name followed by unique hash)
 /// - `input`: the encoding for the arguments (defaults to `PostUrl`)
@@ -883,6 +884,11 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
 ///     - `"GetCbor"`: `GET` request with URL-encoded arguments and CBOR response
 /// - `req` and `res` specify the HTTP request and response types to be used on the server (these
 ///   should usually only be necessary if you are integrating with a server other than Actix/Axum)
+/// - `impl_from`: specifies whether to implement trait `From` for server function's type or not.
+///   By default, if a server function only has one argument, the macro automatically implements the `From` trait
+///   to convert from the argument type to the server function type, and vice versa, allowing you to convert
+///   between them easily. Setting `impl_from` to `false` disables this, which can be necessary for argument types
+///   for which this would create a conflicting implementation. (defaults to `true`)
 ///
 /// ```rust,ignore
 /// #[server(
@@ -891,6 +897,7 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
 ///   endpoint = "my_fn",
 ///   input = Cbor,
 ///   output = Json
+///   impl_from = true
 /// )]
 /// pub async fn my_wacky_server_fn(input: Vec<String>) -> Result<usize, ServerFnError> {
 ///   todo!()
@@ -900,17 +907,17 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
 /// ## Server Function Encodings
 ///
 /// Server functions are designed to allow a flexible combination of `input` and `output` encodings, the set
-/// of which can be found in the [`server_fn::codec`] module.
+/// of which can be found in the [`server_fn::codec`](../server_fn/codec/index.html) module.
 ///
 /// The serialization/deserialization process for server functions consists of a series of steps,
 /// each of which is represented by a different trait:
-/// 1. [`IntoReq`]: The client serializes the [`ServerFn`] argument type into an HTTP request.
-/// 2. The [`Client`] sends the request to the server.
-/// 3. [`FromReq`]: The server deserializes the HTTP request back into the [`ServerFn`] type.
-/// 4. The server calls calls [`ServerFn::run_body`] on the data.
-/// 5. [`IntoRes`]: The server serializes the [`ServerFn::Output`] type into an HTTP response.
-/// 6. The server integration applies any middleware from [`ServerFn::middlewares`] and responds to the request.
-/// 7. [`FromRes`]: The client deserializes the response back into the [`ServerFn::Output`] type.
+/// 1. [`IntoReq`](../server_fn/codec/trait.IntoReq.html): The client serializes the [`ServerFn`](../server_fn/trait.ServerFn.html) argument type into an HTTP request.
+/// 2. The [`Client`](../server_fn/client/trait.Client.html) sends the request to the server.
+/// 3. [`FromReq`](../server_fn/codec/trait.FromReq.html): The server deserializes the HTTP request back into the [`ServerFn`](../server_fn/client/trait.Client.html) type.
+/// 4. The server calls calls [`ServerFn::run_body`](../server_fn/trait.ServerFn.html#tymethod.run_body) on the data.
+/// 5. [`IntoRes`](../server_fn/codec/trait.IntoRes.html): The server serializes the [`ServerFn::Output`](../server_fn/trait.ServerFn.html#associatedtype.Output) type into an HTTP response.
+/// 6. The server integration applies any middleware from [`ServerFn::middleware`](../server_fn/middleware/index.html) and responds to the request.
+/// 7. [`FromRes`](../server_fn/codec/trait.FromRes.html): The client deserializes the response back into the [`ServerFn::Output`](../server_fn/trait.ServerFn.html#associatedtype.Output) type.
 ///
 /// Whatever encoding is provided to `input` should implement `IntoReq` and `FromReq`. Whatever encoding is provided
 /// to `output` should implement `IntoRes` and `FromRes`.
@@ -932,8 +939,8 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
 /// - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done
 ///   inside the function body can’t fail, the processes of serialization/deserialization and the
 ///   network call are fallible.
-///     - [`ServerFnError`] can be generic over some custom error type. If so, that type should implement
-///       [`FromStr`] and [`Display`], but does not need to implement [`Error`]. This is so the value
+///     - [`ServerFnError`](../server_fn/error/enum.ServerFnError.html) can be generic over some custom error type. If so, that type should implement
+///       [`FromStr`](std::str::FromStr) and [`Display`](std::fmt::Display), but does not need to implement [`Error`](std::error::Error). This is so the value
 ///       can be easily serialized and deserialized along with the result.
 /// - **Server functions are part of the public API of your application.** A server function is an
 ///   ad hoc HTTP API endpoint, not a magic formula. Any server function can be accessed by any HTTP
diff --git a/leptos_reactive/src/context.rs b/leptos_reactive/src/context.rs
index 2f098fbc..666db5fb 100644
--- a/leptos_reactive/src/context.rs
+++ b/leptos_reactive/src/context.rs
@@ -86,7 +86,7 @@ use std::any::{Any, TypeId};
 ///
 /// ### Solution
 ///
-/// If you are using the full Leptos framework, you can use the [`Provider`](leptos::Provider)
+/// If you are using the full Leptos framework, you can use the [`Provider`](../leptos/fn.Provider.html)
 /// component to solve this issue.
 ///
 /// ```rust
diff --git a/leptos_reactive/src/memo.rs b/leptos_reactive/src/memo.rs
index 858305a8..760a38d2 100644
--- a/leptos_reactive/src/memo.rs
+++ b/leptos_reactive/src/memo.rs
@@ -86,7 +86,88 @@ pub fn create_memo<T>(f: impl Fn(Option<&T>) -> T + 'static) -> Memo<T>
 where
     T: PartialEq + 'static,
 {
-    Runtime::current().create_memo(f)
+    Runtime::current().create_owning_memo(move |current_value| {
+        let new_value = f(current_value.as_ref());
+        let is_different = current_value.as_ref() != Some(&new_value);
+        (new_value, is_different)
+    })
+}
+
+/// Like [`create_memo`], `create_owning_memo` creates an efficient derived reactive value based on
+/// other reactive values, but with two differences:
+/// 1. The argument to the memo function is owned instead of borrowed.
+/// 2. The function must also return whether the value has changed, as the first element of the tuple.
+///
+/// All of the other caveats and guarantees are the same as the usual "borrowing" memos.
+///
+/// This type of memo is useful for memos which can avoid computation by re-using the last value,
+/// especially slices that need to allocate.
+///
+/// ```
+/// # use leptos_reactive::*;
+/// # fn really_expensive_computation(value: i32) -> i32 { value };
+/// # let runtime = create_runtime();
+/// pub struct State {
+///     name: String,
+///     token: String,
+/// }
+///
+/// let state = create_rw_signal(State {
+///     name: "Alice".to_owned(),
+///     token: "abcdef".to_owned(),
+/// });
+///
+/// // If we used `create_memo`, we'd need to allocate every time the state changes, but by using
+/// // `create_owning_memo` we can allocate only when `state.name` changes.
+/// let name = create_owning_memo(move |old_name| {
+///     state.with(move |state| {
+///         if let Some(name) =
+///             old_name.filter(|old_name| old_name == &state.name)
+///         {
+///             (name, false)
+///         } else {
+///             (state.name.clone(), true)
+///         }
+///     })
+/// });
+/// let set_name = move |name| state.update(|state| state.name = name);
+///
+/// // We can also re-use the last allocation even when the value changes, which is usually faster,
+/// // but may have some caveats (e.g. if the value size is drastically reduced, the memory will
+/// // still be used for the life of the memo).
+/// let token = create_owning_memo(move |old_token| {
+///     state.with(move |state| {
+///         let is_different = old_token.as_ref() != Some(&state.token);
+///         let mut token = old_token.unwrap_or_else(String::new);
+///
+///         if is_different {
+///             token.clone_from(&state.token);
+///         }
+///         (token, is_different)
+///     })
+/// });
+/// let set_token = move |new_token| state.update(|state| state.token = new_token);
+/// # runtime.dispose();
+/// ```
+#[cfg_attr(
+    any(debug_assertions, feature="ssr"),
+    instrument(
+        level = "trace",
+        skip_all,
+        fields(
+            ty = %std::any::type_name::<T>()
+        )
+    )
+)]
+#[track_caller]
+#[inline(always)]
+pub fn create_owning_memo<T>(
+    f: impl Fn(Option<T>) -> (T, bool) + 'static,
+) -> Memo<T>
+where
+    T: PartialEq + 'static,
+{
+    Runtime::current().create_owning_memo(f)
 }
 
 /// An efficient derived reactive value based on other reactive values.
@@ -216,6 +297,65 @@ impl<T> Memo<T> {
     {
         create_memo(f)
     }
+
+    /// Creates a new owning memo from the given function.
+    ///
+    /// This is identical to [`create_owning_memo`].
+    ///
+    /// ```
+    /// # use leptos_reactive::*;
+    /// # fn really_expensive_computation(value: i32) -> i32 { value };
+    /// # let runtime = create_runtime();
+    /// pub struct State {
+    ///     name: String,
+    ///     token: String,
+    /// }
+    ///
+    /// let state = RwSignal::new(State {
+    ///     name: "Alice".to_owned(),
+    ///     token: "abcdef".to_owned(),
+    /// });
+    ///
+    /// // If we used `Memo::new`, we'd need to allocate every time the state changes, but by using
+    /// // `Memo::new_owning` we can allocate only when `state.name` changes.
+    /// let name = Memo::new_owning(move |old_name| {
+    ///     state.with(move |state| {
+    ///         if let Some(name) =
+    ///             old_name.filter(|old_name| old_name == &state.name)
+    ///         {
+    ///             (name, false)
+    ///         } else {
+    ///             (state.name.clone(), true)
+    ///         }
+    ///     })
+    /// });
+    /// let set_name = move |name| state.update(|state| state.name = name);
+    ///
+    /// // We can also re-use the last allocation even when the value changes, which is usually faster,
+    /// // but may have some caveats (e.g. if the value size is drastically reduced, the memory will
+    /// // still be used for the life of the memo).
+    /// let token = Memo::new_owning(move |old_token| {
+    ///     state.with(move |state| {
+    ///         let is_different = old_token.as_ref() != Some(&state.token);
+    ///         let mut token = old_token.unwrap_or_else(String::new);
+    ///
+    ///         if is_different {
+    ///             token.clone_from(&state.token);
+    ///         }
+    ///         (token, is_different)
+    ///     })
+    /// });
+    /// let set_token = move |new_token| state.update(|state| state.token = new_token);
+    /// # runtime.dispose();
+    /// ```
+    #[inline(always)]
+    #[track_caller]
+    pub fn new_owning(f: impl Fn(Option<T>) -> (T, bool) + 'static) -> Memo<T>
+    where
+        T: PartialEq + 'static,
+    {
+        create_owning_memo(f)
+    }
 }
 
 impl<T> Clone for Memo<T>
@@ -524,8 +664,8 @@ impl_get_fn_traits![Memo];
 
 pub(crate) struct MemoState<T, F>
 where
-    T: PartialEq + 'static,
-    F: Fn(Option<&T>) -> T,
+    T: 'static,
+    F: Fn(Option<T>) -> (T, bool),
 {
     pub f: F,
     pub t: PhantomData<T>,
@@ -535,8 +675,8 @@ where
 
 impl<T, F> AnyComputation for MemoState<T, F>
 where
-    T: PartialEq + 'static,
-    F: Fn(Option<&T>) -> T,
+    T: 'static,
+    F: Fn(Option<T>) -> (T, bool),
 {
     #[cfg_attr(
         any(debug_assertions, feature = "ssr"),
@@ -551,24 +691,16 @@ where
         )
     )]
     fn run(&self, value: Rc<RefCell<dyn Any>>) -> bool {
-        let (new_value, is_different) = {
-            let value = value.borrow();
-            let curr_value = value
-                .downcast_ref::<Option<T>>()
-                .expect("to downcast memo value");
-
-            // run the effect
-            let new_value = (self.f)(curr_value.as_ref());
-            let is_different = curr_value.as_ref() != Some(&new_value);
-            (new_value, is_different)
-        };
-        if is_different {
-            let mut value = value.borrow_mut();
-            let curr_value = value
-                .downcast_mut::<Option<T>>()
-                .expect("to downcast memo value");
-            *curr_value = Some(new_value);
-        }
+        let mut value = value.borrow_mut();
+        let curr_value = value
+            .downcast_mut::<Option<T>>()
+            .expect("to downcast memo value");
+
+        // run the memo
+        let (new_value, is_different) = (self.f)(curr_value.take());
+
+        // set new value
+        *curr_value = Some(new_value);
 
         is_different
     }
diff --git a/leptos_reactive/src/oco.rs b/leptos_reactive/src/oco.rs
index 3731f64b..4ae60dde 100644
--- a/leptos_reactive/src/oco.rs
+++ b/leptos_reactive/src/oco.rs
@@ -440,7 +440,7 @@ impl<'a> From<Oco<'a, str>> for Oco<'a, [u8]> {
     }
 }
 
-/// Error returned from [`Oco::try_from`] for unsuccessful
+/// Error returned from `Oco::try_from` for unsuccessful
 /// conversion from `Oco<'_, [u8]>` to `Oco<'_, str>`.
 #[derive(Debug, Clone, thiserror::Error)]
 #[error("invalid utf-8 sequence: {_0}")]
diff --git a/leptos_reactive/src/runtime.rs b/leptos_reactive/src/runtime.rs
index 61deaf8c..2798fd61 100644
--- a/leptos_reactive/src/runtime.rs
+++ b/leptos_reactive/src/runtime.rs
@@ -1201,12 +1201,12 @@ impl RuntimeId {
 
     #[track_caller]
     #[inline(always)]
-    pub(crate) fn create_memo<T>(
+    pub(crate) fn create_owning_memo<T>(
         self,
-        f: impl Fn(Option<&T>) -> T + 'static,
+        f: impl Fn(Option<T>) -> (T, bool) + 'static,
     ) -> Memo<T>
     where
-        T: PartialEq + Any + 'static,
+        T: 'static,
     {
         Memo {
             id: self.create_concrete_memo(
diff --git a/leptos_server/src/action.rs b/leptos_server/src/action.rs
index 294a3b0b..4f3316d4 100644
--- a/leptos_server/src/action.rs
+++ b/leptos_server/src/action.rs
@@ -113,7 +113,7 @@ where
     /// Create an [Action].
     ///
     /// [Action] is a type of [Signal] which represent imperative calls to
-    /// an asynchronous function. Where a [Resource] is driven as a function
+    /// an asynchronous function. Where a [Resource](leptos_reactive::Resource) is driven as a function
     /// of a [Signal], [Action]s are [Action::dispatch]ed by events or handlers.
     ///
     /// ```rust
@@ -242,7 +242,7 @@ impl<I> Action<I, Result<I::Output, ServerFnError<I::Error>>>
 where
     I: ServerFn + 'static,
 {
-    /// Create an [Action] to imperatively call a [server_fn::server] function.
+    /// Create an [Action] to imperatively call a [server](leptos_macro::server) function.
     ///
     /// The struct representing your server function's arguments should be
     /// provided to the [Action]. Unless specified as an argument to the server
diff --git a/meta/Cargo.toml b/meta/Cargo.toml
index 22b5d665..2392026e 100644
--- a/meta/Cargo.toml
+++ b/meta/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "leptos_meta"
-version = "0.6.5"
+version = "0.6.6"
 edition = "2021"
 authors = ["Greg Johnston"]
 license = "MIT"
diff --git a/router/Cargo.toml b/router/Cargo.toml
index 581ed123..10014559 100644
--- a/router/Cargo.toml
+++ b/router/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "leptos_router"
-version = "0.6.5"
+version = "0.6.6"
 edition = "2021"
 authors = ["Greg Johnston"]
 license = "MIT"
diff --git a/router/src/components/route.rs b/router/src/components/route.rs
index 1dcda396..369cada0 100644
--- a/router/src/components/route.rs
+++ b/router/src/components/route.rs
@@ -1,6 +1,7 @@
 use crate::{
     matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch},
     ParamsMap, RouterContext, SsrMode, StaticData, StaticMode, StaticParamsMap,
+    TrailingSlash,
 };
 use leptos::{leptos_dom::Transparent, *};
 use std::{
@@ -17,6 +18,16 @@ thread_local! {
     static ROUTE_ID: Cell<usize> = const { Cell::new(0) };
 }
 
+// RouteDefinition.id is `pub` and required to be unique.
+// Should we make this public so users can generate unique IDs?
+pub(in crate::components) fn new_route_id() -> usize {
+    ROUTE_ID.with(|id| {
+        let next = id.get() + 1;
+        id.set(next);
+        next
+    })
+}
+
 /// Represents an HTTP method that can be handled by this route.
 #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
 pub enum Method {
@@ -65,6 +76,11 @@ pub fn Route<E, F, P>(
     /// accessed with [`use_route_data`](crate::use_route_data).
     #[prop(optional, into)]
     data: Option<Loader>,
+    /// How this route should handle trailing slashes in its path.
+    /// Overrides any setting applied to [`crate::components::Router`].
+    /// Serves as a default for any inner Routes.
+    #[prop(optional)]
+    trailing_slash: Option<TrailingSlash>,
     /// `children` may be empty or include nested routes.
     #[prop(optional)]
     children: Option<Children>,
@@ -83,6 +99,7 @@ where
         data,
         None,
         None,
+        trailing_slash,
     )
 }
 
@@ -115,6 +132,11 @@ pub fn ProtectedRoute<P, E, F, C>(
     /// accessed with [`use_route_data`](crate::use_route_data).
     #[prop(optional, into)]
     data: Option<Loader>,
+    /// How this route should handle trailing slashes in its path.
+    /// Overrides any setting applied to [`crate::components::Router`].
+    /// Serves as a default for any inner Routes.
+    #[prop(optional)]
+    trailing_slash: Option<TrailingSlash>,
     /// `children` may be empty or include nested routes.
     #[prop(optional)]
     children: Option<Children>,
@@ -143,6 +165,7 @@ where
         data,
         None,
         None,
+        trailing_slash,
     )
 }
 
@@ -171,6 +194,11 @@ pub fn StaticRoute<E, F, P, S>(
     /// accessed with [`use_route_data`](crate::use_route_data).
     #[prop(optional, into)]
     data: Option<Loader>,
+    /// How this route should handle trailing slashes in its path.
+    /// Overrides any setting applied to [`crate::components::Router`].
+    /// Serves as a default for any inner Routes.
+    #[prop(optional)]
+    trailing_slash: Option<TrailingSlash>,
     /// `children` may be empty or include nested routes.
     #[prop(optional)]
     children: Option<Children>,
@@ -193,6 +221,7 @@ where
         data,
         Some(mode),
         Some(Arc::new(static_params)),
+        trailing_slash,
     )
 }
 
@@ -210,6 +239,7 @@ pub(crate) fn define_route(
     data: Option<Loader>,
     static_mode: Option<StaticMode>,
     static_params: Option<StaticData>,
+    trailing_slash: Option<TrailingSlash>,
 ) -> RouteDefinition {
     let children = children
         .map(|children| {
@@ -226,14 +256,8 @@ pub(crate) fn define_route(
         })
         .unwrap_or_default();
 
-    let id = ROUTE_ID.with(|id| {
-        let next = id.get() + 1;
-        id.set(next);
-        next
-    });
-
     RouteDefinition {
-        id,
+        id: new_route_id(),
         path,
         children,
         view,
@@ -242,6 +266,7 @@ pub(crate) fn define_route(
         data,
         static_mode,
         static_params,
+        trailing_slash,
     }
 }
 
diff --git a/router/src/components/router.rs b/router/src/components/router.rs
index 5ec2e04c..838592cf 100644
--- a/router/src/components/router.rs
+++ b/router/src/components/router.rs
@@ -41,13 +41,16 @@ pub fn Router(
     /// A signal that will be set while the navigation process is underway.
     #[prop(optional, into)]
     set_is_routing: Option<SignalSetter<bool>>,
+    /// How trailing slashes should be handled in [`Route`] paths.
+    #[prop(optional)]
+    trailing_slash: TrailingSlash,
     /// The `<Router/>` should usually wrap your whole page. It can contain
     /// any elements, and should include a [`Routes`](crate::Routes) component somewhere
     /// to define and display [`Route`](crate::Route)s.
     children: Children,
 ) -> impl IntoView {
     // create a new RouterContext and provide it to every component beneath the router
-    let router = RouterContext::new(base, fallback);
+    let router = RouterContext::new(base, fallback, trailing_slash);
     provide_context(router);
     provide_context(GlobalSuspenseContext::new());
     if let Some(set_is_routing) = set_is_routing {
@@ -103,6 +106,7 @@ pub(crate) struct RouterContextInner {
     id: usize,
     pub location: Location,
     pub base: RouteContext,
+    trailing_slash: TrailingSlash,
     pub possible_routes: RefCell<Option<Vec<Branch>>>,
     #[allow(unused)] // used in CSR/hydrate
     base_path: String,
@@ -139,6 +143,7 @@ impl RouterContext {
     pub(crate) fn new(
         base: Option<&'static str>,
         fallback: Option<fn() -> View>,
+        trailing_slash: TrailingSlash,
     ) -> Self {
         cfg_if! {
             if #[cfg(any(feature = "csr", feature = "hydrate"))] {
@@ -220,6 +225,7 @@ impl RouterContext {
             path_stack: store_value(vec![location.pathname.get_untracked()]),
             location,
             base,
+            trailing_slash,
             history: Box::new(history),
 
             reference,
@@ -258,6 +264,10 @@ impl RouterContext {
         self.inner.id
     }
 
+    pub(crate) fn trailing_slash(&self) -> TrailingSlash {
+        self.inner.trailing_slash.clone()
+    }
+
     /// A list of all possible routes this router can match.
     pub fn possible_branches(&self) -> Vec<Branch> {
         self.inner
@@ -515,3 +525,64 @@ impl Default for NavigateOptions {
         }
     }
 }
+
+/// Declares how you would like to handle trailing slashes in Route paths. This
+/// can be set on [`Router`] and overridden in [`crate::components::Route`]
+#[derive(Default, Clone, Debug, PartialEq, Eq)]
+pub enum TrailingSlash {
+    /// This is the default behavior as of Leptos 0.5.  Trailing slashes in your
+    /// `Route` path are stripped. i.e.: the following two route declarations
+    /// are equivalent:
+    ///  * `<Route path="/foo">`
+    ///  * `<Route path="/foo/">`
+    #[default]
+    Drop,
+
+    /// This mode will respect your path as it is written. Ex:
+    ///  * If you specify `<Route path="/foo">`, then `/foo` matches, but
+    ///    `/foo/` does not.
+    ///  * If you specify `<Route path="/foo/">`, then `/foo/` matches, but
+    ///    `/foo` does not.
+    Exact,
+
+    /// Like `Exact`, this mode respects your path as-written. But it will also
+    /// add redirects to the specified path if a user nagivates to a URL that is
+    /// off by only the trailing slash.
+    ///
+    /// Given `<Route path="/foo">`
+    ///  * Visiting `/foo` is valid.
+    ///  * Visiting `/foo/` serves a redirect to `/foo`
+    ///
+    /// Given `<Route path="/foo/">`
+    ///  * Visiting `/foo` serves a redirect to `/foo/`
+    ///  * Visiting `/foo/` is valid.
+    Redirect,
+}
+
+impl TrailingSlash {
+    /// Should we redirect requests that come in with the wrong (extra/missing) trailng slash?
+    pub(crate) fn should_redirect(&self) -> bool {
+        use TrailingSlash::*;
+        match self {
+            Redirect => true,
+            Drop | Exact => false,
+        }
+    }
+
+    pub(crate) fn normalize_route_path(&self, path: &mut String) {
+        if !self.should_drop() {
+            return;
+        }
+        while path.ends_with('/') {
+            path.pop();
+        }
+    }
+
+    fn should_drop(&self) -> bool {
+        use TrailingSlash::*;
+        match self {
+            Redirect | Exact => false,
+            Drop => true,
+        }
+    }
+}
diff --git a/router/src/components/routes.rs b/router/src/components/routes.rs
index c83d1dd9..832313f1 100644
--- a/router/src/components/routes.rs
+++ b/router/src/components/routes.rs
@@ -1,10 +1,12 @@
 use crate::{
     animation::*,
+    components::route::new_route_id,
     matching::{
         expand_optionals, get_route_matches, join_paths, Branch, Matcher,
         RouteDefinition, RouteMatch,
     },
-    use_is_back_navigation, RouteContext, RouterContext, SetIsRouting,
+    use_is_back_navigation, use_route, NavigateOptions, Redirect, RouteContext,
+    RouterContext, SetIsRouting, TrailingSlash,
 };
 use leptos::{leptos_dom::HydrationCtx, *};
 use std::{
@@ -82,7 +84,7 @@ pub fn Routes(
     let base_route = router.base();
     let base = base.unwrap_or_default();
 
-    Branches::initialize(router_id, &base, children());
+    Branches::initialize(&router, &base, children());
 
     #[cfg(feature = "ssr")]
     if let Some(context) = use_context::<crate::PossibleBranchContext>() {
@@ -164,7 +166,7 @@ pub fn AnimatedRoutes(
     let base_route = router.base();
     let base = base.unwrap_or_default();
 
-    Branches::initialize(router_id, &base, children());
+    Branches::initialize(&router, &base, children());
 
     #[cfg(feature = "ssr")]
     if let Some(context) = use_context::<crate::PossibleBranchContext>() {
@@ -278,7 +280,7 @@ thread_local! {
 }
 
 impl Branches {
-    pub fn initialize(router_id: usize, base: &str, children: Fragment) {
+    pub fn initialize(router: &RouterContext, base: &str, children: Fragment) {
         BRANCHES.with(|branches| {
             #[cfg(debug_assertions)]
             {
@@ -293,9 +295,9 @@ impl Branches {
             }
 
             let mut current = branches.borrow_mut();
-            if !current.contains_key(&(router_id, Cow::from(base))) {
+            if !current.contains_key(&(router.id(), Cow::from(base))) {
                 let mut branches = Vec::new();
-                let children = children
+                let mut children = children
                     .as_children()
                     .iter()
                     .filter_map(|child| {
@@ -315,6 +317,7 @@ impl Branches {
                     })
                     .cloned()
                     .collect::<Vec<_>>();
+                inherit_settings(&mut children, router);
                 create_branches(
                     &children,
                     base,
@@ -323,7 +326,7 @@ impl Branches {
                     true,
                     base,
                 );
-                current.insert((router_id, Cow::Owned(base.into())), branches);
+                current.insert((router.id(), Cow::Owned(base.into())), branches);
             }
         })
     }
@@ -344,6 +347,36 @@ impl Branches {
     }
 }
 
+// <Route>s may inherit settings from each other or <Router>.
+// This mutates RouteDefinitions to propagate those settings.
+fn inherit_settings(children: &mut [RouteDefinition], router: &RouterContext) {
+    struct InheritProps {
+        trailing_slash: Option<TrailingSlash>,
+    }
+    fn route_def_inherit(
+        children: &mut [RouteDefinition],
+        inherited: InheritProps,
+    ) {
+        for child in children {
+            if child.trailing_slash.is_none() {
+                child.trailing_slash = inherited.trailing_slash.clone();
+            }
+            route_def_inherit(
+                &mut child.children,
+                InheritProps {
+                    trailing_slash: child.trailing_slash.clone(),
+                },
+            );
+        }
+    }
+    route_def_inherit(
+        children,
+        InheritProps {
+            trailing_slash: Some(router.trailing_slash()),
+        },
+    );
+}
+
 fn route_states(
     router_id: usize,
     base: String,
@@ -553,6 +586,7 @@ pub(crate) struct RouterState {
 
 #[derive(Debug, Clone, PartialEq)]
 pub struct RouteData {
+    // This ID is always the same as key.id.  Deprecate?
     pub id: usize,
     pub key: RouteDefinition,
     pub pattern: String,
@@ -647,24 +681,110 @@ fn create_routes(
             parents_path, route_def.path
         );
     }
+    let trailing_slash = route_def
+        .trailing_slash
+        .clone()
+        .expect("trailng_slash should be set by this point");
     let mut acc = Vec::new();
     for original_path in expand_optionals(&route_def.path) {
-        let path = join_paths(base, &original_path);
+        let mut path = join_paths(base, &original_path).to_string();
+        trailing_slash.normalize_route_path(&mut path);
         let pattern = if is_leaf {
             path
+        } else if let Some((path, _splat)) = path.split_once("/*") {
+            path.to_string()
         } else {
-            path.split("/*")
-                .next()
-                .map(|n| n.to_string())
-                .unwrap_or(path)
+            path
         };
-        acc.push(RouteData {
+
+        let route_data = RouteData {
             key: route_def.clone(),
             id: route_def.id,
             matcher: Matcher::new_with_partial(&pattern, !is_leaf),
             pattern,
             original_path: original_path.into_owned(),
-        });
+        };
+
+        if route_data.matcher.is_wildcard() {
+            // already handles trailing_slash
+        } else if let Some(redirect_route) = redirect_route_for(route_def) {
+            let pattern = &redirect_route.path;
+            let redirect_route_data = RouteData {
+                id: redirect_route.id,
+                matcher: Matcher::new_with_partial(pattern, !is_leaf),
+                pattern: pattern.to_owned(),
+                original_path: pattern.to_owned(),
+                key: redirect_route,
+            };
+            acc.push(redirect_route_data);
+        }
+
+        acc.push(route_data);
     }
     acc
 }
+
+/// A new route that redirects to `route` with the correct trailng slash.
+fn redirect_route_for(route: &RouteDefinition) -> Option<RouteDefinition> {
+    if matches!(route.path.as_str(), "" | "/") {
+        // Root paths are an exception to the rule and are always equivalent:
+        return None;
+    }
+
+    let trailing_slash = route
+        .trailing_slash
+        .clone()
+        .expect("trailing_slash should be defined by now");
+
+    if !trailing_slash.should_redirect() {
+        return None;
+    }
+
+    // Are we creating a new route that adds or removes a slash?
+    let add_slash = route.path.ends_with('/');
+    let view = Rc::new(move || {
+        view! {
+            <FixTrailingSlash add_slash />
+        }
+        .into_view()
+    });
+
+    let new_pattern = if add_slash {
+        // If we need to add a slash, we need to match on the path w/o it:
+        route.path.trim_end_matches('/').to_string()
+    } else {
+        format!("{}/", route.path)
+    };
+    let new_route = RouteDefinition {
+        path: new_pattern,
+        children: vec![],
+        data: None,
+        methods: route.methods,
+        id: new_route_id(),
+        view,
+        ssr_mode: route.ssr_mode,
+        static_mode: route.static_mode,
+        static_params: None,
+        trailing_slash: None, // Shouldn't be needed/used from here on out
+    };
+
+    Some(new_route)
+}
+
+#[component]
+fn FixTrailingSlash(add_slash: bool) -> impl IntoView {
+    let route = use_route();
+    let path = if add_slash {
+        format!("{}/", route.path())
+    } else {
+        route.path().trim_end_matches('/').to_string()
+    };
+    let options = NavigateOptions {
+        replace: true,
+        ..Default::default()
+    };
+
+    view! {
+        <Redirect path options/>
+    }
+}
diff --git a/router/src/extract_routes.rs b/router/src/extract_routes.rs
index 8111d607..8c494616 100644
--- a/router/src/extract_routes.rs
+++ b/router/src/extract_routes.rs
@@ -1,6 +1,9 @@
+mod test_extract_routes;
+
 use crate::{
-    Branch, Method, RouterIntegrationContext, ServerIntegration, SsrMode,
-    StaticDataMap, StaticMode, StaticParamsMap, StaticPath,
+    provide_server_redirect, Branch, Method, RouterIntegrationContext,
+    ServerIntegration, SsrMode, StaticDataMap, StaticMode, StaticParamsMap,
+    StaticPath,
 };
 use leptos::*;
 use std::{
@@ -42,6 +45,9 @@ impl RouteListing {
     }
 
     /// The path this route handles.
+    ///
+    /// This should be formatted for whichever web server integegration is being used. (ex: leptos-actix.)
+    /// When returned from leptos-router, it matches `self.leptos_path()`.  
     pub fn path(&self) -> &str {
         &self.path
     }
@@ -128,21 +134,9 @@ where
 {
     let runtime = create_runtime();
 
-    let integration = ServerIntegration {
-        path: "http://leptos.rs/".to_string(),
-    };
-
-    provide_context(RouterIntegrationContext::new(integration));
-    let branches = PossibleBranchContext::default();
-    provide_context(branches.clone());
-
-    additional_context();
-
-    leptos::suppress_resource_load(true);
-    _ = app_fn().into_view();
-    leptos::suppress_resource_load(false);
-
+    let branches = get_branches(app_fn, additional_context);
     let branches = branches.0.borrow();
+
     let mut static_data_map: StaticDataMap = HashMap::new();
     let routes = branches
         .iter()
@@ -182,3 +176,29 @@ where
     runtime.dispose();
     (routes, static_data_map)
 }
+
+fn get_branches<IV>(
+    app_fn: impl Fn() -> IV + 'static + Clone,
+    additional_context: impl Fn() + 'static + Clone,
+) -> PossibleBranchContext
+where
+    IV: IntoView + 'static,
+{
+    let integration = ServerIntegration {
+        path: "http://leptos.rs/".to_string(),
+    };
+
+    provide_context(RouterIntegrationContext::new(integration));
+    let branches = PossibleBranchContext::default();
+    provide_context(branches.clone());
+    // Suppress startup warning about using <Redirect/> without ServerRedirectFunction:
+    provide_server_redirect(|_str| ());
+
+    additional_context();
+
+    leptos::suppress_resource_load(true);
+    _ = app_fn().into_view();
+    leptos::suppress_resource_load(false);
+
+    branches
+}
diff --git a/router/src/matching/matcher.rs b/router/src/matching/matcher.rs
index 9351267b..cda21758 100644
--- a/router/src/matching/matcher.rs
+++ b/router/src/matching/matcher.rs
@@ -31,14 +31,8 @@ impl Matcher {
             Some((p, s)) => (p, Some(s.to_string())),
             None => (path, None),
         };
-        let segments = pattern
-            .split('/')
-            .filter(|n| !n.is_empty())
-            .map(|n| n.to_string())
-            .collect::<Vec<_>>();
-
+        let segments: Vec<String> = get_segments(pattern);
         let len = segments.len();
-
         Self {
             splat,
             segments,
@@ -49,10 +43,7 @@ impl Matcher {
 
     #[doc(hidden)]
     pub fn test(&self, location: &str) -> Option<PathMatch> {
-        let loc_segments = location
-            .split('/')
-            .filter(|n| !n.is_empty())
-            .collect::<Vec<_>>();
+        let loc_segments: Vec<&str> = get_segments(location);
 
         let loc_len = loc_segments.len();
         let len_diff: i32 = loc_len as i32 - self.len as i32;
@@ -107,4 +98,24 @@ impl Matcher {
             Some(PathMatch { path, params })
         }
     }
+
+    #[doc(hidden)]
+    pub(crate) fn is_wildcard(&self) -> bool {
+        self.splat.is_some()
+    }
+}
+
+fn get_segments<'a, S: From<&'a str>>(pattern: &'a str) -> Vec<S> {
+    // URL root paths ("/" and "") are equivalent and treated as 0-segment paths.
+    // non-root paths with trailing slashes get extra empty segment at the end.
+    // This makes sure that segment matching is trailing-slash sensitive.
+    let mut segments: Vec<S> = pattern
+        .split('/')
+        .filter(|p| !p.is_empty())
+        .map(Into::into)
+        .collect();
+    if !segments.is_empty() && pattern.ends_with('/') {
+        segments.push("".into());
+    }
+    segments
 }
diff --git a/router/src/matching/resolve_path.rs b/router/src/matching/resolve_path.rs
index 3f236cef..043e2a9f 100644
--- a/router/src/matching/resolve_path.rs
+++ b/router/src/matching/resolve_path.rs
@@ -51,7 +51,14 @@ fn has_scheme(path: &str) -> bool {
 
 #[doc(hidden)]
 fn normalize(path: &str, omit_slash: bool) -> Cow<'_, str> {
-    let s = path.trim_start_matches('/').trim_end_matches('/');
+    let s = path.trim_start_matches('/');
+    let trim_end = s
+        .chars()
+        .rev()
+        .take_while(|c| *c == '/')
+        .count()
+        .saturating_sub(1);
+    let s = &s[0..s.len() - trim_end];
     if s.is_empty() || omit_slash || begins_with_query_or_hash(s) {
         s.into()
     } else {
@@ -70,9 +77,10 @@ fn begins_with_query_or_hash(text: &str) -> bool {
 }
 
 fn remove_wildcard(text: &str) -> String {
-    text.split_once('*')
-        .map(|(prefix, _)| prefix.trim_end_matches('/'))
+    text.rsplit_once('*')
+        .map(|(prefix, _)| prefix)
         .unwrap_or(text)
+        .trim_end_matches('/')
         .to_string()
 }
 
@@ -83,4 +91,14 @@ mod tests {
     fn normalize_query_string_with_opening_slash() {
         assert_eq!(normalize("/?foo=bar", false), "?foo=bar");
     }
+
+    #[test]
+    fn normalize_retain_trailing_slash() {
+        assert_eq!(normalize("foo/bar/", false), "/foo/bar/");
+    }
+
+    #[test]
+    fn normalize_dedup_trailing_slashes() {
+        assert_eq!(normalize("foo/bar/////", false), "/foo/bar/");
+    }
 }
diff --git a/router/src/matching/route.rs b/router/src/matching/route.rs
index 24fe6b67..cf73e141 100644
--- a/router/src/matching/route.rs
+++ b/router/src/matching/route.rs
@@ -1,4 +1,4 @@
-use crate::{Loader, Method, SsrMode, StaticData, StaticMode};
+use crate::{Loader, Method, SsrMode, StaticData, StaticMode, TrailingSlash};
 use leptos::leptos_dom::View;
 use std::rc::Rc;
 
@@ -25,6 +25,8 @@ pub struct RouteDefinition {
     pub static_mode: Option<StaticMode>,
     /// The data required to fill any dynamic segments in the path during static rendering.
     pub static_params: Option<StaticData>,
+    /// How a trailng slash in `path` should be handled.
+    pub trailing_slash: Option<TrailingSlash>,
 }
 
 impl core::fmt::Debug for RouteDefinition {
@@ -34,6 +36,7 @@ impl core::fmt::Debug for RouteDefinition {
             .field("children", &self.children)
             .field("ssr_mode", &self.ssr_mode)
             .field("static_render", &self.static_mode)
+            .field("trailing_slash", &self.trailing_slash)
             .finish()
     }
 }
diff --git a/server_fn/src/lib.rs b/server_fn/src/lib.rs
index 6ba6656d..ee803960 100644
--- a/server_fn/src/lib.rs
+++ b/server_fn/src/lib.rs
@@ -92,7 +92,7 @@
 //! 6. The server integration applies any middleware from [`ServerFn::middlewares`] and responds to the request.
 //! 7. [`FromRes`]: The client deserializes the response back into the [`ServerFn::Output`] type.
 //!
-//! [server]: <https://docs.rs/server_fn/latest/server_fn/attr.server.html>
+//! [server]: ../leptos/attr.server.html
 //! [`serde_qs`]: <https://docs.rs/serde_qs/latest/serde_qs/>
 //! [`cbor`]: <https://docs.rs/cbor/latest/cbor/>
 
@@ -472,7 +472,7 @@ pub mod axum {
 
     /// Explicitly register a server function. This is only necessary if you are
     /// running the server in a WASM environment (or a rare environment that the
-    /// `inventory`).
+    /// `inventory` crate won't work in.).
     pub fn register_explicit<T>()
     where
         T: ServerFn<
@@ -556,7 +556,7 @@ pub mod actix {
 
     /// Explicitly register a server function. This is only necessary if you are
     /// running the server in a WASM environment (or a rare environment that the
-    /// `inventory`).
+    /// `inventory` crate won't work in.).
     pub fn register_explicit<T>()
     where
         T: ServerFn<
diff --git a/server_fn_macro/src/lib.rs b/server_fn_macro/src/lib.rs
index 613987aa..8560d555 100644
--- a/server_fn_macro/src/lib.rs
+++ b/server_fn_macro/src/lib.rs
@@ -119,6 +119,7 @@ pub fn server_macro_impl(
         res_ty,
         client,
         custom_wrapper,
+        impl_from,
     } = args;
     let prefix = prefix.unwrap_or_else(|| Literal::string(default_path));
     let fn_path = fn_path.unwrap_or_else(|| Literal::string(""));
@@ -206,8 +207,11 @@ pub fn server_macro_impl(
         FnArg::Receiver(_) => None,
         FnArg::Typed(t) => Some((&t.pat, &t.ty)),
     });
-    let from_impl =
-        (body.inputs.len() == 1 && first_field.is_some()).then(|| {
+    let impl_from = impl_from.map(|v| v.value).unwrap_or(true);
+    let from_impl = (body.inputs.len() == 1
+        && first_field.is_some()
+        && impl_from)
+        .then(|| {
             let field = first_field.unwrap();
             let (name, ty) = field;
             quote! {
@@ -676,6 +680,7 @@ struct ServerFnArgs {
     client: Option<Type>,
     custom_wrapper: Option<Path>,
     builtin_encoding: bool,
+    impl_from: Option<LitBool>,
 }
 
 impl Parse for ServerFnArgs {
@@ -693,6 +698,7 @@ impl Parse for ServerFnArgs {
         let mut res_ty: Option<Type> = None;
         let mut client: Option<Type> = None;
         let mut custom_wrapper: Option<Path> = None;
+        let mut impl_from: Option<LitBool> = None;
 
         let mut use_key_and_value = false;
         let mut arg_pos = 0;
@@ -800,6 +806,14 @@ impl Parse for ServerFnArgs {
                             ));
                         }
                         custom_wrapper = Some(stream.parse()?);
+                    } else if key == "impl_from" {
+                        if impl_from.is_some() {
+                            return Err(syn::Error::new(
+                                key.span(),
+                                "keyword argument repeated: `impl_from`",
+                            ));
+                        }
+                        impl_from = Some(stream.parse()?);
                     } else {
                         return Err(lookahead.error());
                     }
@@ -895,6 +909,7 @@ impl Parse for ServerFnArgs {
             res_ty,
             client,
             custom_wrapper,
+            impl_from,
         })
     }
 }


Test patch:
diff --git a/integrations/actix/tests/extract_routes.rs b/integrations/actix/tests/extract_routes.rs
new file mode 100644
index 00000000..67765b18
--- /dev/null
+++ b/integrations/actix/tests/extract_routes.rs
@@ -0,0 +1,148 @@
+use leptos::*;
+use leptos_actix::generate_route_list;
+use leptos_router::{Route, Router, Routes, TrailingSlash};
+
+#[component]
+fn DefaultApp() -> impl IntoView {
+    let view = || view! { "" };
+    view! {
+        <Router>
+            <Routes>
+                <Route path="/foo" view/>
+                <Route path="/bar/" view/>
+                <Route path="/baz/:id" view/>
+                <Route path="/baz/:name/" view/>
+                <Route path="/baz/*any" view/>
+            </Routes>
+        </Router>
+    }
+}
+
+#[test]
+fn test_default_app() {
+    let routes = generate_route_list(DefaultApp);
+
+    // We still have access to the original (albeit normalized) Leptos paths:
+    assert_same(
+        &routes,
+        |r| r.leptos_path(),
+        &["/bar", "/baz/*any", "/baz/:id", "/baz/:name", "/foo"],
+    );
+
+    // ... But leptos-actix has also reformatted "paths" to work for Actix.
+    assert_same(
+        &routes,
+        |r| r.path(),
+        &["/bar", "/baz/{id}", "/baz/{name}", "/baz/{tail:.*}", "/foo"],
+    );
+}
+
+#[component]
+fn ExactApp() -> impl IntoView {
+    let view = || view! { "" };
+    let trailing_slash = TrailingSlash::Exact;
+    view! {
+        <Router trailing_slash>
+            <Routes>
+                <Route path="/foo" view/>
+                <Route path="/bar/" view/>
+                <Route path="/baz/:id" view/>
+                <Route path="/baz/:name/" view/>
+                <Route path="/baz/*any" view/>
+            </Routes>
+        </Router>
+    }
+}
+
+#[test]
+fn test_exact_app() {
+    let routes = generate_route_list(ExactApp);
+
+    // In Exact mode, the Leptos paths no longer have their trailing slashes stripped:
+    assert_same(
+        &routes,
+        |r| r.leptos_path(),
+        &["/bar/", "/baz/*any", "/baz/:id", "/baz/:name/", "/foo"],
+    );
+
+    // Actix paths also have trailing slashes as a result:
+    assert_same(
+        &routes,
+        |r| r.path(),
+        &[
+            "/bar/",
+            "/baz/{id}",
+            "/baz/{name}/",
+            "/baz/{tail:.*}",
+            "/foo",
+        ],
+    );
+}
+
+#[component]
+fn RedirectApp() -> impl IntoView {
+    let view = || view! { "" };
+    let trailing_slash = TrailingSlash::Redirect;
+    view! {
+        <Router trailing_slash>
+            <Routes>
+                <Route path="/foo" view/>
+                <Route path="/bar/" view/>
+                <Route path="/baz/:id" view/>
+                <Route path="/baz/:name/" view/>
+                <Route path="/baz/*any" view/>
+            </Routes>
+        </Router>
+    }
+}
+
+#[test]
+fn test_redirect_app() {
+    let routes = generate_route_list(RedirectApp);
+
+    assert_same(
+        &routes,
+        |r| r.leptos_path(),
+        &[
+            "/bar",
+            "/bar/",
+            "/baz/*any",
+            "/baz/:id",
+            "/baz/:id/",
+            "/baz/:name",
+            "/baz/:name/",
+            "/foo",
+            "/foo/",
+        ],
+    );
+
+    // ... But leptos-actix has also reformatted "paths" to work for Actix.
+    assert_same(
+        &routes,
+        |r| r.path(),
+        &[
+            "/bar",
+            "/bar/",
+            "/baz/{id}",
+            "/baz/{id}/",
+            "/baz/{name}",
+            "/baz/{name}/",
+            "/baz/{tail:.*}",
+            "/foo",
+            "/foo/",
+        ],
+    );
+}
+
+fn assert_same<'t, T, F, U>(
+    input: &'t Vec<T>,
+    mapper: F,
+    expected_sorted_values: &[U],
+) where
+    F: Fn(&'t T) -> U + 't,
+    U: Ord + std::fmt::Debug,
+{
+    let mut values: Vec<U> = input.iter().map(mapper).collect();
+    values.sort();
+    assert_eq!(values, expected_sorted_values);
+}
diff --git a/leptos_reactive/tests/memo.rs b/leptos_reactive/tests/memo.rs
index 35eb8d9a..0d072cd0 100644
--- a/leptos_reactive/tests/memo.rs
+++ b/leptos_reactive/tests/memo.rs
@@ -212,3 +212,84 @@ fn dynamic_dependencies() {
 
     runtime.dispose();
 }
+
+#[test]
+fn owning_memo_slice() {
+    use std::rc::Rc;
+    let runtime = create_runtime();
+
+    // this could be serialized to and from localstorage with miniserde
+    pub struct State {
+        name: String,
+        token: String,
+    }
+
+    let state = create_rw_signal(State {
+        name: "Alice".to_owned(),
+        token: "is this a token????".to_owned(),
+    });
+
+    // We can allocate only when `state.name` changes
+    let name = create_owning_memo(move |old_name| {
+        state.with(move |state| {
+            if let Some(name) =
+                old_name.filter(|old_name| old_name == &state.name)
+            {
+                (name, false)
+            } else {
+                (state.name.clone(), true)
+            }
+        })
+    });
+    let set_name = move |name| state.update(|state| state.name = name);
+
+    // We can also re-use the last token allocation, which may be even better if the tokens are
+    // always of the same length
+    let token = create_owning_memo(move |old_token| {
+        state.with(move |state| {
+            let is_different = old_token.as_ref() != Some(&state.token);
+            let mut token = old_token.unwrap_or_else(String::new);
+
+            if is_different {
+                token.clone_from(&state.token);
+            }
+            (token, is_different)
+        })
+    });
+    let set_token =
+        move |new_token| state.update(|state| state.token = new_token);
+
+    let count_name_updates = Rc::new(std::cell::Cell::new(0));
+    assert_eq!(count_name_updates.get(), 0);
+    create_isomorphic_effect({
+        let count_name_updates = Rc::clone(&count_name_updates);
+        move |_| {
+            name.track();
+            count_name_updates.set(count_name_updates.get() + 1);
+        }
+    });
+    assert_eq!(count_name_updates.get(), 1);
+
+    let count_token_updates = Rc::new(std::cell::Cell::new(0));
+    assert_eq!(count_token_updates.get(), 0);
+    create_isomorphic_effect({
+        let count_token_updates = Rc::clone(&count_token_updates);
+        move |_| {
+            token.track();
+            count_token_updates.set(count_token_updates.get() + 1);
+        }
+    });
+    assert_eq!(count_token_updates.get(), 1);
+
+    set_name("Bob".to_owned());
+    name.with(|name| assert_eq!(name, "Bob"));
+    assert_eq!(count_name_updates.get(), 2);
+    assert_eq!(count_token_updates.get(), 1);
+
+    set_token("this is not a token!".to_owned());
+    token.with(|token| assert_eq!(token, "this is not a token!"));
+    assert_eq!(count_name_updates.get(), 2);
+    assert_eq!(count_token_updates.get(), 2);
+
+    runtime.dispose();
+}
diff --git a/router/src/extract_routes/test_extract_routes.rs b/router/src/extract_routes/test_extract_routes.rs
new file mode 100644
index 00000000..69b41bb6
--- /dev/null
+++ b/router/src/extract_routes/test_extract_routes.rs
@@ -0,0 +1,258 @@
+// This is here, vs /router/tests/, because it accesses some `pub(crate)`
+// features to test crate internals that wouldn't be available there.
+
+#![cfg(all(test, feature = "ssr"))]
+
+use crate::*;
+use itertools::Itertools;
+use leptos::*;
+use std::{cell::RefCell, rc::Rc};
+
+#[component]
+fn DefaultApp() -> impl IntoView {
+    let view = || view! { "" };
+    view! {
+        <Router>
+            <Routes>
+                <Route path="/foo" view/>
+                <Route path="/bar/" view/>
+                <Route path="/baz/:id" view/>
+                <Route path="/name/:name/" view/>
+                <Route path="/any/*any" view/>
+            </Routes>
+        </Router>
+    }
+}
+
+#[component]
+fn ExactApp() -> impl IntoView {
+    let view = || view! { "" };
+    let trailing_slash = TrailingSlash::Exact;
+    view! {
+        <Router trailing_slash>
+            <Routes>
+                <Route path="/foo" view/>
+                <Route path="/bar/" view/>
+                <Route path="/baz/:id" view/>
+                <Route path="/name/:name/" view/>
+                <Route path="/any/*any" view/>
+            </Routes>
+        </Router>
+    }
+}
+
+#[component]
+fn RedirectApp() -> impl IntoView {
+    let view = || view! { "" };
+    let trailing_slash = TrailingSlash::Redirect;
+    view! {
+        <Router trailing_slash>
+            <Routes>
+                <Route path="/foo" view/>
+                <Route path="/bar/" view/>
+                <Route path="/baz/:id" view/>
+                <Route path="/name/:name/" view/>
+                <Route path="/any/*any" view/>
+            </Routes>
+        </Router>
+    }
+}
+#[test]
+fn test_generated_routes_default() {
+    // By default, we use the behavior as of Leptos 0.5, which is equivalent to TrailingSlash::Drop.
+    assert_generated_paths(
+        DefaultApp,
+        &["/any/*any", "/bar", "/baz/:id", "/foo", "/name/:name"],
+    );
+}
+
+#[test]
+fn test_generated_routes_exact() {
+    // Allow users to precisely define whether slashes are present:
+    assert_generated_paths(
+        ExactApp,
+        &["/any/*any", "/bar/", "/baz/:id", "/foo", "/name/:name/"],
+    );
+}
+
+#[test]
+fn test_generated_routes_redirect() {
+    // TralingSlashes::Redirect generates paths to redirect to the path with the "correct" trailing slash ending (or lack thereof).
+    assert_generated_paths(
+        RedirectApp,
+        &[
+            "/any/*any",
+            "/bar",
+            "/bar/",
+            "/baz/:id",
+            "/baz/:id/",
+            "/foo",
+            "/foo/",
+            "/name/:name",
+            "/name/:name/",
+        ],
+    )
+}
+
+#[test]
+fn test_rendered_redirect() {
+    // Given an app that uses TrailngSlsahes::Redirect, rendering the redirected path
+    // should render the redirect. Other paths should not.
+
+    let expected_redirects = &[
+        ("/bar", "/bar/"),
+        ("/baz/some_id/", "/baz/some_id"),
+        ("/name/some_name", "/name/some_name/"),
+        ("/foo/", "/foo"),
+    ];
+
+    let redirect_result = Rc::new(RefCell::new(Option::None));
+    let rc = redirect_result.clone();
+    let server_redirect = move |new_value: &str| {
+        rc.replace(Some(new_value.to_string()));
+    };
+
+    let _runtime = Disposable(create_runtime());
+    let history = TestHistory::new("/");
+    provide_context(RouterIntegrationContext::new(history.clone()));
+    provide_server_redirect(server_redirect);
+
+    // We expect these redirects to exist:
+    for (src, dest) in expected_redirects {
+        let loc = format!("https://example.com{src}");
+        history.goto(&loc);
+        redirect_result.replace(None);
+        RedirectApp().into_view().render_to_string();
+        let redirected_to = redirect_result.borrow().clone();
+        assert!(
+            redirected_to.is_some(),
+            "Should redirect from {src} to {dest}"
+        );
+        assert_eq!(redirected_to.unwrap(), *dest);
+    }
+
+    // But the destination paths shouldn't themselves redirect:
+    redirect_result.replace(None);
+    for (_src, dest) in expected_redirects {
+        let loc = format!("https://example.com{dest}");
+        history.goto(&loc);
+        RedirectApp().into_view().render_to_string();
+        let redirected_to = redirect_result.borrow().clone();
+        assert!(
+            redirected_to.is_none(),
+            "Destination of redirect shouldn't also redirect: {dest}"
+        );
+    }
+}
+
+struct Disposable(RuntimeId);
+
+// If the test fails, and we don't dispose, we get irrelevant panics.
+impl Drop for Disposable {
+    fn drop(&mut self) {
+        self.0.dispose()
+    }
+}
+
+#[derive(Clone)]
+struct TestHistory {
+    loc: RwSignal<LocationChange>,
+}
+
+impl TestHistory {
+    fn new(initial: &str) -> Self {
+        let lc = LocationChange {
+            value: initial.to_owned(),
+            ..Default::default()
+        };
+        Self {
+            loc: create_rw_signal(lc),
+        }
+    }
+
+    fn goto(&self, loc: &str) {
+        let change = LocationChange {
+            value: loc.to_string(),
+            ..Default::default()
+        };
+
+        self.navigate(&change);
+    }
+}
+
+impl History for TestHistory {
+    fn location(&self) -> ReadSignal<LocationChange> {
+        self.loc.read_only()
+    }
+
+    fn navigate(&self, new_loc: &LocationChange) {
+        self.loc.update(|loc| loc.value = new_loc.value.clone())
+    }
+}
+
+// WARNING!
+//
+// Despite generate_route_list_inner() using a new leptos_reactive::RuntimeID
+// each time we call this function, somehow Routes are leaked between different
+// apps. To avoid that, make sure to put each call in a separate #[test] method.
+//
+// TODO: Better isolation for different apps to avoid this issue?
+fn assert_generated_paths<F, IV>(app: F, expected_sorted_paths: &[&str])
+where
+    F: Clone + Fn() -> IV + 'static,
+    IV: IntoView + 'static,
+{
+    let (routes, static_data) = generate_route_list_inner(app);
+
+    let mut paths = routes.iter().map(|route| route.path()).collect_vec();
+    paths.sort();
+
+    assert_eq!(paths, expected_sorted_paths);
+
+    let mut keys = static_data.keys().collect_vec();
+    keys.sort();
+    assert_eq!(paths, keys);
+
+    // integrations can update "path" to be valid for themselves, but
+    // when routes are returned by leptos_router, these are equal:
+    assert!(routes
+        .iter()
+        .all(|route| route.path() == route.leptos_path()));
+}
+
+#[test]
+fn test_unique_route_ids() {
+    let branches = get_branches(RedirectApp);
+    assert!(!branches.is_empty());
+
+    assert!(branches
+        .iter()
+        .flat_map(|branch| &branch.routes)
+        .map(|route| route.id)
+        .all_unique());
+}
+
+#[test]
+fn test_unique_route_patterns() {
+    let branches = get_branches(RedirectApp);
+    assert!(!branches.is_empty());
+
+    assert!(branches
+        .iter()
+        .flat_map(|branch| &branch.routes)
+        .map(|route| route.pattern.as_str())
+        .all_unique());
+}
+
+fn get_branches<F, IV>(app_fn: F) -> Vec<Branch>
+where
+    F: Fn() -> IV + Clone + 'static,
+    IV: IntoView + 'static,
+{
+    let runtime = create_runtime();
+    let additional_context = || ();
+    let branches = super::get_branches(app_fn, additional_context);
+    let branches = branches.0.borrow().clone();
+    runtime.dispose();
+    branches
+}
diff --git a/router/tests/join_paths.rs b/router/tests/join_paths.rs
index a0f68284..f1fadfec 100644
--- a/router/tests/join_paths.rs
+++ b/router/tests/join_paths.rs
@@ -44,5 +44,14 @@ cfg_if! {
             assert_eq!(join_paths("/foo", ":bar/baz"), "/foo/:bar/baz");
             assert_eq!(join_paths("", ":bar/baz"), "/:bar/baz");
         }
+
+        // Additional tests NOT from Solid Router:
+        #[test]
+        fn join_paths_for_root() {
+            assert_eq!(join_paths("", ""), "");
+            assert_eq!(join_paths("", "/"), "");
+            assert_eq!(join_paths("/", ""), "");
+            assert_eq!(join_paths("/", "/"), "");
+        }
     }
 }
diff --git a/router/tests/trailing_slashes.rs b/router/tests/trailing_slashes.rs
new file mode 100644
index 00000000..317ffce8
--- /dev/null
+++ b/router/tests/trailing_slashes.rs
@@ -0,0 +1,59 @@
+//! Some extra tests for Matcher NOT based on SolidJS's tests cases (as in matcher.rs)
+
+use leptos_router::*;
+
+#[test]
+fn trailing_slashes_match_exactly() {
+    let matcher = Matcher::new("/foo/");
+    assert_matches(&matcher, "/foo/");
+    assert_no_match(&matcher, "/foo");
+
+    let matcher = Matcher::new("/foo/bar/");
+    assert_matches(&matcher, "/foo/bar/");
+    assert_no_match(&matcher, "/foo/bar");
+
+    let matcher = Matcher::new("/");
+    assert_matches(&matcher, "/");
+    assert_matches(&matcher, "");
+
+    let matcher = Matcher::new("");
+    assert_matches(&matcher, "");
+
+    // Despite returning a pattern of "", web servers (known: Actix-Web and Axum)
+    // may send us a path of "/". We should match those at the root:
+    assert_matches(&matcher, "/");
+}
+
+#[cfg(feature = "ssr")]
+#[test]
+fn trailing_slashes_params_match_exactly() {
+    let matcher = Matcher::new("/foo/:bar/");
+    assert_matches(&matcher, "/foo/bar/");
+    assert_matches(&matcher, "/foo/42/");
+    assert_matches(&matcher, "/foo/%20/");
+
+    assert_no_match(&matcher, "/foo/bar");
+    assert_no_match(&matcher, "/foo/42");
+    assert_no_match(&matcher, "/foo/%20");
+
+    let m = matcher.test("/foo/asdf/").unwrap();
+    assert_eq!(m.params, params_map! { "bar" => "asdf" });
+}
+
+fn assert_matches(matcher: &Matcher, path: &str) {
+    assert!(
+        matches(matcher, path),
+        "{matcher:?} should match path {path:?}"
+    );
+}
+
+fn assert_no_match(matcher: &Matcher, path: &str) {
+    assert!(
+        !matches(matcher, path),
+        "{matcher:?} should NOT match path {path:?}"
+    );
+}
+
+fn matches(m: &Matcher, loc: &str) -> bool {
+    m.test(loc).is_some()
+}