From 2e2dd08dfef340784b17ad807af2586256e4485e Mon Sep 17 00:00:00 2001
From: stifskere
Date: Sat, 4 Apr 2026 20:30:38 +0200
Subject: [PATCH 01/16] feat: add actix support for yew-link
---
.gitignore | 1 +
Cargo.lock | 563 +++++++++++++++++-
Cargo.toml | 1 +
.../ssr_router/src/bin/ssr_router_server.rs | 3 +-
packages/yew-link/Cargo.toml | 2 +
packages/yew-link/src/lib.rs | 51 +-
packages/yew-link/src/services.rs | 87 +++
.../advanced-topics/server-side-rendering.mdx | 9 +-
8 files changed, 646 insertions(+), 71 deletions(-)
create mode 100644 packages/yew-link/src/services.rs
diff --git a/.gitignore b/.gitignore
index bec5f8c71aa..d3244a16012 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ dist/
*.iml
/.idea/
/.vscode/
+.nvim.lua
diff --git a/Cargo.lock b/Cargo.lock
index 057db7f0c56..59c3ca68212 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,210 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "actix-codec"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
+dependencies = [
+ "bitflags 2.11.0",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "actix-http"
+version = "3.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e"
+dependencies = [
+ "actix-codec",
+ "actix-rt",
+ "actix-service",
+ "actix-tls",
+ "actix-utils",
+ "base64",
+ "bitflags 2.11.0",
+ "brotli",
+ "bytes",
+ "bytestring",
+ "derive_more",
+ "encoding_rs",
+ "flate2",
+ "foldhash 0.1.5",
+ "futures-core",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "language-tags",
+ "local-channel",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rand 0.9.2",
+ "sha1",
+ "smallvec",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "zstd",
+]
+
+[[package]]
+name = "actix-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "actix-router"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
+dependencies = [
+ "bytestring",
+ "cfg-if",
+ "http 0.2.12",
+ "regex",
+ "regex-lite",
+ "serde",
+ "tracing",
+]
+
+[[package]]
+name = "actix-rt"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63"
+dependencies = [
+ "futures-core",
+ "tokio",
+]
+
+[[package]]
+name = "actix-server"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502"
+dependencies = [
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "futures-core",
+ "futures-util",
+ "mio",
+ "socket2 0.5.10",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "actix-service"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "actix-tls"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f"
+dependencies = [
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "futures-core",
+ "impl-more",
+ "pin-project-lite",
+ "tokio",
+ "tokio-rustls 0.23.4",
+ "tokio-util",
+ "tracing",
+ "webpki-roots",
+]
+
+[[package]]
+name = "actix-utils"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8"
+dependencies = [
+ "local-waker",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "actix-web"
+version = "4.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf"
+dependencies = [
+ "actix-codec",
+ "actix-http",
+ "actix-macros",
+ "actix-router",
+ "actix-rt",
+ "actix-server",
+ "actix-service",
+ "actix-tls",
+ "actix-utils",
+ "actix-web-codegen",
+ "bytes",
+ "bytestring",
+ "cfg-if",
+ "cookie",
+ "derive_more",
+ "encoding_rs",
+ "foldhash 0.1.5",
+ "futures-core",
+ "futures-util",
+ "impl-more",
+ "itoa",
+ "language-tags",
+ "log",
+ "mime",
+ "once_cell",
+ "pin-project-lite",
+ "regex",
+ "regex-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "smallvec",
+ "socket2 0.6.3",
+ "time",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "actix-web-codegen"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8"
+dependencies = [
+ "actix-router",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -17,6 +221,21 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -352,6 +571,27 @@ dependencies = [
"yew",
]
+[[package]]
+name = "brotli"
+version = "8.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
[[package]]
name = "build-examples"
version = "0.1.0"
@@ -398,6 +638,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "bytestring"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
+dependencies = [
+ "bytes",
+]
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -609,6 +858,26 @@ dependencies = [
"yew-agent",
]
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cookie"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -776,10 +1045,12 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
+ "convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.117",
+ "unicode-xid",
]
[[package]]
@@ -991,6 +1262,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1428,6 +1705,25 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "h2"
version = "0.4.13"
@@ -1464,7 +1760,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -1501,7 +1797,7 @@ dependencies = [
"hash32",
"rustc_version",
"serde",
- "spin",
+ "spin 0.9.8",
"stable_deref_trait",
]
@@ -1598,7 +1894,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2",
+ "h2 0.4.13",
"http 1.4.0",
"http-body",
"httparse",
@@ -1620,10 +1916,10 @@ dependencies = [
"http 1.4.0",
"hyper",
"hyper-util",
- "rustls",
+ "rustls 0.23.37",
"rustls-pki-types",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tower-service",
]
@@ -1644,7 +1940,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
@@ -1806,6 +2102,12 @@ dependencies = [
"yew",
]
+[[package]]
+name = "impl-more"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
+
[[package]]
name = "implicit-clone"
version = "0.6.0"
@@ -2042,6 +2344,12 @@ dependencies = [
"yew",
]
+[[package]]
+name = "language-tags"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2121,6 +2429,23 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+[[package]]
+name = "local-channel"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "local-waker",
+]
+
+[[package]]
+name = "local-waker"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2206,6 +2531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
@@ -2335,6 +2661,29 @@ dependencies = [
"unicode-width",
]
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "password_strength"
version = "0.1.0"
@@ -2588,8 +2937,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
- "rustls",
- "socket2",
+ "rustls 0.23.37",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -2607,9 +2956,9 @@ dependencies = [
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
- "ring",
+ "ring 0.17.14",
"rustc-hash",
- "rustls",
+ "rustls 0.23.37",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
@@ -2627,7 +2976,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2",
+ "socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -2706,6 +3055,15 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.11.0",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -2753,7 +3111,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "h2",
+ "h2 0.4.13",
"http 1.4.0",
"http-body",
"http-body-util",
@@ -2766,14 +3124,14 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls",
+ "rustls 0.23.37",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
- "tokio-rustls",
+ "tokio-rustls 0.26.4",
"tower",
"tower-http",
"tower-service",
@@ -2783,6 +3141,21 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "ring"
+version = "0.16.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
+dependencies = [
+ "cc",
+ "libc",
+ "once_cell",
+ "spin 0.5.2",
+ "untrusted 0.7.1",
+ "web-sys",
+ "winapi",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -2793,7 +3166,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
- "untrusted",
+ "untrusted 0.9.0",
"windows-sys 0.52.0",
]
@@ -2848,6 +3221,18 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.20.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99"
+dependencies = [
+ "log",
+ "ring 0.16.20",
+ "sct",
+ "webpki",
+]
+
[[package]]
name = "rustls"
version = "0.23.37"
@@ -2895,7 +3280,7 @@ dependencies = [
"jni",
"log",
"once_cell",
- "rustls",
+ "rustls 0.23.37",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
@@ -2918,9 +3303,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
- "ring",
+ "ring 0.17.14",
"rustls-pki-types",
- "untrusted",
+ "untrusted 0.9.0",
]
[[package]]
@@ -2965,6 +3350,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+dependencies = [
+ "ring 0.17.14",
+ "untrusted 0.9.0",
+]
+
[[package]]
name = "security-framework"
version = "3.6.0"
@@ -3144,6 +3539,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "socket2"
version = "0.6.3"
@@ -3154,6 +3559,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "spin"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
[[package]]
name = "spin"
version = "0.9.8"
@@ -3432,10 +3843,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [
"deranged",
+ "itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
+ "time-macros",
]
[[package]]
@@ -3444,6 +3857,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
+[[package]]
+name = "time-macros"
+version = "0.2.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "timer"
version = "0.1.0"
@@ -3510,9 +3933,10 @@ dependencies = [
"bytes",
"libc",
"mio",
+ "parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2",
+ "socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -3528,13 +3952,24 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
+dependencies = [
+ "rustls 0.20.9",
+ "tokio",
+ "webpki",
+]
+
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls",
+ "rustls 0.23.37",
"tokio",
]
@@ -3770,18 +4205,36 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -4090,6 +4543,16 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "webpki"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
+dependencies = [
+ "ring 0.17.14",
+ "untrusted 0.9.0",
+]
+
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
@@ -4099,6 +4562,15 @@ dependencies = [
"rustls-pki-types",
]
+[[package]]
+name = "webpki-roots"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87"
+dependencies = [
+ "webpki",
+]
+
[[package]]
name = "website-test"
version = "0.1.0"
@@ -4120,6 +4592,22 @@ dependencies = [
"yew-router",
]
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4129,6 +4617,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -4520,6 +5014,7 @@ dependencies = [
name = "yew-link"
version = "0.1.0"
dependencies = [
+ "actix-web",
"axum",
"gloo-net 0.6.0",
"lru",
@@ -4717,6 +5212,34 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "zxcvbn"
version = "3.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 7642f115493..f91afc42554 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -60,3 +60,4 @@ thiserror = "2.0"
bincode = { version = "2.0.0-rc.3", features = ["serde"] }
reqwest = "0.13"
axum = "0.8"
+actix-web = { version = "4.13.0", features = ["rustls", "macros"] }
diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/ssr_router/src/bin/ssr_router_server.rs
index 3112c032aa3..e0915821437 100644
--- a/examples/ssr_router/src/bin/ssr_router_server.rs
+++ b/examples/ssr_router/src/bin/ssr_router_server.rs
@@ -25,7 +25,8 @@ use tower::Service;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use yew::platform::Runtime;
-use yew_link::{linked_state_handler, Resolver, ResolverProp};
+use yew_link::axum::linked_state_handler;
+use yew_link::{Resolver, ResolverProp};
use yew_router::prelude::Routable;
// We use jemalloc as it produces better performance.
diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml
index 6960f30090a..7c1ec6a7965 100644
--- a/packages/yew-link/Cargo.toml
+++ b/packages/yew-link/Cargo.toml
@@ -20,12 +20,14 @@ wasm-bindgen-futures = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
axum = { workspace = true, optional = true }
+actix-web = { workspace = true, optional = true }
[features]
default = []
ssr = ["yew/ssr"]
hydration = ["yew/hydration"]
axum = ["dep:axum"]
+actix = ["dep:actix-web"]
[lints]
workspace = true
diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs
index 19adc8fb7d1..4e09dd807b7 100644
--- a/packages/yew-link/src/lib.rs
+++ b/packages/yew-link/src/lib.rs
@@ -679,51 +679,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult(|id| async move { db::get_post(id).await })
- /// );
- ///
- /// let app = axum::Router::new()
- /// .route("/api/link", axum::routing::post(linked_state_handler))
- /// .with_state(resolver);
- /// ```
- pub async fn linked_state_handler(
- State(resolver): State>,
- Json(req): Json,
- ) -> impl IntoResponse {
- match resolver.resolve_request(&req).await {
- Ok(val) => (
- StatusCode::OK,
- Json(LinkResponse {
- ok: Some(val),
- error: None,
- }),
- ),
- Err(err_val) => (
- StatusCode::UNPROCESSABLE_ENTITY,
- Json(LinkResponse {
- ok: None,
- error: Some(err_val),
- }),
- ),
- }
- }
-}
+#[cfg(not(target_arch = "wasm32"))]
+mod services;
-#[cfg(all(feature = "axum", not(target_arch = "wasm32")))]
-pub use service::linked_state_handler;
+#[cfg(not(target_arch = "wasm32"))]
+pub use services::*;
diff --git a/packages/yew-link/src/services.rs b/packages/yew-link/src/services.rs
new file mode 100644
index 00000000000..5408207008d
--- /dev/null
+++ b/packages/yew-link/src/services.rs
@@ -0,0 +1,87 @@
+#[cfg(all(feature = "axum", not(target_arch = "wasm32")))]
+pub mod axum {
+ use std::sync::Arc;
+
+ use axum::extract::State;
+ use axum::http::StatusCode;
+ use axum::response::IntoResponse;
+ use axum::Json;
+
+ use crate::{LinkRequest, LinkResponse, Resolver};
+
+ /// Axum handler that resolves [`LinkRequest`]s.
+ ///
+ /// ```ignore
+ /// let resolver = Arc::new(
+ /// Resolver::new()
+ /// .register::(|id| async move { db::get_post(id).await })
+ /// );
+ ///
+ /// let app = axum::Router::new()
+ /// .route("/api/link", axum::routing::post(linked_state_handler))
+ /// .with_state(resolver);
+ /// ```
+ pub async fn linked_state_handler(
+ State(resolver): State>,
+ Json(req): Json,
+ ) -> impl IntoResponse {
+ match resolver.resolve_request(&req).await {
+ Ok(val) => (
+ StatusCode::OK,
+ Json(LinkResponse {
+ ok: Some(val),
+ error: None,
+ }),
+ ),
+ Err(err_val) => (
+ StatusCode::UNPROCESSABLE_ENTITY,
+ Json(LinkResponse {
+ ok: None,
+ error: Some(err_val),
+ }),
+ ),
+ }
+ }
+}
+
+#[cfg(all(feature = "actix", not(target_arch = "wasm32")))]
+pub mod service {
+ use actix_web::web::{Data, Json};
+ use actix_web::HttpResponse;
+
+ use crate::{LinkRequest, LinkResponse, Resolver};
+
+ /// Actix handler that resolves [`LinkRequest`]s.
+ ///
+ /// ```ignore
+ /// let resolver = Data::new(
+ /// Resolver::new()
+ /// .register::(|id| async move { db::get_post(id).await })
+ /// )
+ ///
+ /// HttpServer::new(move || {
+ /// App::new()
+ /// .route("/api/link", post().to(linked_state_handler))
+ /// .data(resolver)
+ /// })
+ /// .bind(("0.0.0.0", 8080))?
+ /// .run()
+ /// .await
+ /// ```
+ pub async fn linked_state_handler(
+ resolver: Data,
+ Json(req): Json,
+ ) -> HttpResponse {
+ match resolver.resolve_request(&req).await {
+ Ok(val) => HttpResponse::Ok().json(LinkResponse {
+ ok: Some(val),
+ error: None,
+ }),
+
+ Err(err_val) => HttpResponse::UnprocessableEntity().json(LinkResponse {
+ ok: None,
+ error: Some(err_val),
+ }),
+ }
+ }
+}
diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx
index ea544b0d34a..c8d8c1357c3 100644
--- a/website/docs/advanced-topics/server-side-rendering.mdx
+++ b/website/docs/advanced-topics/server-side-rendering.mdx
@@ -199,8 +199,8 @@ Multiple components requesting the same `(T, Input)` concurrently share a single
#### Server setup
-```rust ,ignore
-use yew_link::{Resolver, linked_state_handler};
+```rust, ignore
+use yew_link::{Resolver, axum::linked_state_handler};
let resolver = Arc::new(
Resolver::new()
@@ -212,9 +212,12 @@ let app = axum::Router::new()
.with_state(resolver);
```
+There is multiple service providers, this example shows how to use `axum`,
+but you can use actix in a similar way.
+
#### Component usage
-```rust ,ignore
+```rust, ignore
use yew_link::{use_linked_state, LinkProvider};
#[component]
From 11bee31fe15cd3920b2a53d0a575cc52a3e39ede Mon Sep 17 00:00:00 2001
From: stifskere
Date: Sun, 5 Apr 2026 00:04:51 +0200
Subject: [PATCH 02/16] fix: #4113 errors from first commit
---
Cargo.lock | 148 ++----------------
Cargo.toml | 1 -
packages/yew-link/Cargo.toml | 2 +-
packages/yew-link/src/services.rs | 10 +-
.../advanced-topics/server-side-rendering.mdx | 11 +-
5 files changed, 25 insertions(+), 147 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 89e6e972503..b9efd21f9a4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -28,7 +28,6 @@ dependencies = [
"actix-codec",
"actix-rt",
"actix-service",
- "actix-tls",
"actix-utils",
"base64",
"bitflags 2.11.0",
@@ -121,25 +120,6 @@ dependencies = [
"pin-project-lite",
]
-[[package]]
-name = "actix-tls"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f"
-dependencies = [
- "actix-rt",
- "actix-service",
- "actix-utils",
- "futures-core",
- "impl-more",
- "pin-project-lite",
- "tokio",
- "tokio-rustls 0.23.4",
- "tokio-util",
- "tracing",
- "webpki-roots",
-]
-
[[package]]
name = "actix-utils"
version = "3.0.1"
@@ -163,7 +143,6 @@ dependencies = [
"actix-rt",
"actix-server",
"actix-service",
- "actix-tls",
"actix-utils",
"actix-web-codegen",
"bytes",
@@ -1850,7 +1829,7 @@ dependencies = [
"hash32",
"rustc_version",
"serde",
- "spin 0.9.8",
+ "spin",
"stable_deref_trait",
]
@@ -1968,10 +1947,10 @@ dependencies = [
"http 1.4.0",
"hyper",
"hyper-util",
- "rustls 0.23.37",
+ "rustls",
"rustls-pki-types",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tower-service",
]
@@ -3022,7 +3001,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
- "rustls 0.23.37",
+ "rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
@@ -3041,9 +3020,9 @@ dependencies = [
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
- "ring 0.17.14",
+ "ring",
"rustc-hash",
- "rustls 0.23.37",
+ "rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
@@ -3209,14 +3188,14 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
- "rustls 0.23.37",
+ "rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
- "tokio-rustls 0.26.4",
+ "tokio-rustls",
"tower",
"tower-http",
"tower-service",
@@ -3226,21 +3205,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "ring"
-version = "0.16.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
-dependencies = [
- "cc",
- "libc",
- "once_cell",
- "spin 0.5.2",
- "untrusted 0.7.1",
- "web-sys",
- "winapi",
-]
-
[[package]]
name = "ring"
version = "0.17.14"
@@ -3251,7 +3215,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.17",
"libc",
- "untrusted 0.9.0",
+ "untrusted",
"windows-sys 0.52.0",
]
@@ -3300,18 +3264,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "rustls"
-version = "0.20.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99"
-dependencies = [
- "log",
- "ring 0.16.20",
- "sct",
- "webpki",
-]
-
[[package]]
name = "rustls"
version = "0.23.37"
@@ -3359,7 +3311,7 @@ dependencies = [
"jni",
"log",
"once_cell",
- "rustls 0.23.37",
+ "rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
@@ -3382,9 +3334,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
- "ring 0.17.14",
+ "ring",
"rustls-pki-types",
- "untrusted 0.9.0",
+ "untrusted",
]
[[package]]
@@ -3429,16 +3381,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-[[package]]
-name = "sct"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
-dependencies = [
- "ring 0.17.14",
- "untrusted 0.9.0",
-]
-
[[package]]
name = "security-framework"
version = "3.6.0"
@@ -3647,12 +3589,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "spin"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
-
[[package]]
name = "spin"
version = "0.9.8"
@@ -4049,24 +3985,13 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "tokio-rustls"
-version = "0.23.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
-dependencies = [
- "rustls 0.20.9",
- "tokio",
- "webpki",
-]
-
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
- "rustls 0.23.37",
+ "rustls",
"tokio",
]
@@ -4376,12 +4301,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
-[[package]]
-name = "untrusted"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
-
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -4692,16 +4611,6 @@ dependencies = [
"syn 1.0.109",
]
-[[package]]
-name = "webpki"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
-dependencies = [
- "ring 0.17.14",
- "untrusted 0.9.0",
-]
-
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
@@ -4711,15 +4620,6 @@ dependencies = [
"rustls-pki-types",
]
-[[package]]
-name = "webpki-roots"
-version = "0.22.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87"
-dependencies = [
- "webpki",
-]
-
[[package]]
name = "website-test"
version = "0.1.0"
@@ -4741,22 +4641,6 @@ dependencies = [
"yew-router",
]
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -4766,12 +4650,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
[[package]]
name = "windows-core"
version = "0.62.2"
diff --git a/Cargo.toml b/Cargo.toml
index 1564fd26659..4eca6101ac4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -62,4 +62,3 @@ thiserror = "2.0"
bincode = { version = "2.0.0-rc.3", features = ["serde"] }
reqwest = "0.13"
axum = "0.8"
-actix-web = { version = "4.13.0", features = ["rustls", "macros"] }
diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml
index 7c1ec6a7965..3fd2f597a20 100644
--- a/packages/yew-link/Cargo.toml
+++ b/packages/yew-link/Cargo.toml
@@ -20,7 +20,7 @@ wasm-bindgen-futures = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
axum = { workspace = true, optional = true }
-actix-web = { workspace = true, optional = true }
+actix-web = { version = "4.13", optional = true }
[features]
default = []
diff --git a/packages/yew-link/src/services.rs b/packages/yew-link/src/services.rs
index 5408207008d..6611aa5a7aa 100644
--- a/packages/yew-link/src/services.rs
+++ b/packages/yew-link/src/services.rs
@@ -1,4 +1,4 @@
-#[cfg(all(feature = "axum", not(target_arch = "wasm32")))]
+#[cfg(feature = "axum")]
pub mod axum {
use std::sync::Arc;
@@ -44,8 +44,8 @@ pub mod axum {
}
}
-#[cfg(all(feature = "actix", not(target_arch = "wasm32")))]
-pub mod service {
+#[cfg(feature = "actix")]
+pub mod actix_web {
use actix_web::web::{Data, Json};
use actix_web::HttpResponse;
@@ -57,12 +57,12 @@ pub mod service {
/// let resolver = Data::new(
/// Resolver::new()
/// .register::(|id| async move { db::get_post(id).await })
- /// )
+ /// );
///
/// HttpServer::new(move || {
/// App::new()
/// .route("/api/link", post().to(linked_state_handler))
- /// .data(resolver)
+ /// .app_data(resolver)
/// })
/// .bind(("0.0.0.0", 8080))?
/// .run()
diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx
index c8d8c1357c3..3248ada9df0 100644
--- a/website/docs/advanced-topics/server-side-rendering.mdx
+++ b/website/docs/advanced-topics/server-side-rendering.mdx
@@ -148,7 +148,7 @@ The `yew-link` crate provides a higher-level abstraction that unifies all three
3. **Wrap** your app in ``.
4. **Call** `use_linked_state::(input)` in any component.
-```rust ,ignore
+```rust, ignore
use yew_link::{linked_state, LinkedState};
#[derive(Clone, Serialize, Deserialize)]
@@ -171,7 +171,7 @@ The macro generates the `LinkedState` and (server-only) `LinkedStateResolve` tra
If `resolve` can fail, declare `type Error`:
-```rust ,ignore
+```rust, ignore
#[linked_state]
impl LinkedState for Post {
type Context = DbPool;
@@ -200,7 +200,8 @@ Multiple components requesting the same `(T, Input)` concurrently share a single
#### Server setup
```rust, ignore
-use yew_link::{Resolver, axum::linked_state_handler};
+use yew_link::Resolver;
+use axum::linked_state_handler;
let resolver = Arc::new(
Resolver::new()
@@ -212,7 +213,7 @@ let app = axum::Router::new()
.with_state(resolver);
```
-There is multiple service providers, this example shows how to use `axum`,
+There are multiple service providers, this example shows how to use `axum`,
but you can use actix in a similar way.
#### Component usage
@@ -298,7 +299,7 @@ until `rendered()` method is called.
## Example
-```rust ,ignore
+```rust, ignore
use yew::prelude::*;
use yew::Renderer;
From 032699b03531d5e9e544333b5bcf196d0fb98a64 Mon Sep 17 00:00:00 2001
From: stifskere
Date: Sun, 5 Apr 2026 00:29:24 +0200
Subject: [PATCH 03/16] fix: #4113 server-side-rendering documentation
codeblock type separators
---
website/docs/advanced-topics/server-side-rendering.mdx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx
index 3248ada9df0..f45aa48a911 100644
--- a/website/docs/advanced-topics/server-side-rendering.mdx
+++ b/website/docs/advanced-topics/server-side-rendering.mdx
@@ -148,7 +148,7 @@ The `yew-link` crate provides a higher-level abstraction that unifies all three
3. **Wrap** your app in ``.
4. **Call** `use_linked_state::(input)` in any component.
-```rust, ignore
+```rust ,ignore
use yew_link::{linked_state, LinkedState};
#[derive(Clone, Serialize, Deserialize)]
@@ -171,7 +171,7 @@ The macro generates the `LinkedState` and (server-only) `LinkedStateResolve` tra
If `resolve` can fail, declare `type Error`:
-```rust, ignore
+```rust ,ignore
#[linked_state]
impl LinkedState for Post {
type Context = DbPool;
@@ -199,7 +199,7 @@ Multiple components requesting the same `(T, Input)` concurrently share a single
#### Server setup
-```rust, ignore
+```rust ,ignore
use yew_link::Resolver;
use axum::linked_state_handler;
@@ -218,7 +218,7 @@ but you can use actix in a similar way.
#### Component usage
-```rust, ignore
+```rust ,ignore
use yew_link::{use_linked_state, LinkProvider};
#[component]
@@ -299,7 +299,7 @@ until `rendered()` method is called.
## Example
-```rust, ignore
+```rust ,ignore
use yew::prelude::*;
use yew::Renderer;
From 2623a6c668a09d37bc29557c5088ecb6474c39a5 Mon Sep 17 00:00:00 2001
From: stifskere
Date: Sun, 5 Apr 2026 18:16:29 +0200
Subject: [PATCH 04/16] docs: #4113 add an example of actix yew_link resolver
in the documentation
---
.../advanced-topics/server-side-rendering.mdx | 21 +++++++++++++++++--
1 file changed, 19 insertions(+), 2 deletions(-)
diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx
index f45aa48a911..9e43a8c69cc 100644
--- a/website/docs/advanced-topics/server-side-rendering.mdx
+++ b/website/docs/advanced-topics/server-side-rendering.mdx
@@ -200,8 +200,7 @@ Multiple components requesting the same `(T, Input)` concurrently share a single
#### Server setup
```rust ,ignore
-use yew_link::Resolver;
-use axum::linked_state_handler;
+use yew_link::{Resolver, axum::linked_state_handler};
let resolver = Arc::new(
Resolver::new()
@@ -216,6 +215,24 @@ let app = axum::Router::new()
There are multiple service providers, this example shows how to use `axum`,
but you can use actix in a similar way.
+```rust ,ignore
+use yew_link::{Resolver, actix::linked_state_handler};
+
+let resolver = Data::new(
+ Resolver::new()
+ .register_linked::(db_pool.clone())
+);
+
+HttpServer::new(move || {
+ App::new()
+ .route("/api/link", post().to(linked_state_handler))
+ .app_data(resolver)
+})
+ .bind(("0.0.0.0", 8080))?
+ .run()
+ .await
+```
+
#### Component usage
```rust ,ignore
From 11d8ffb93e9f08dbad979a82da15e4afad8dcbf0 Mon Sep 17 00:00:00 2001
From: stifskere
Date: Mon, 6 Apr 2026 17:21:04 +0200
Subject: [PATCH 05/16] chore: #4113 put services module under feature enabled
by any of actix or axum
---
Cargo.lock | 143 ++++--
examples/ssr_router/Cargo.toml | 56 ---
examples/ssr_router/README.md | 15 -
examples/ssr_router/index.html | 17 -
.../ssr_router/src/bin/ssr_router_hydrate.rs | 18 -
.../ssr_router/src/bin/ssr_router_server.rs | 196 --------
examples/ssr_router/src/lib.rs | 468 ------------------
examples/ssr_router/tests/e2e.rs | 199 --------
packages/yew-link/Cargo.toml | 5 +-
packages/yew-link/src/lib.rs | 6 +-
10 files changed, 117 insertions(+), 1006 deletions(-)
delete mode 100644 examples/ssr_router/Cargo.toml
delete mode 100644 examples/ssr_router/README.md
delete mode 100644 examples/ssr_router/index.html
delete mode 100644 examples/ssr_router/src/bin/ssr_router_hydrate.rs
delete mode 100644 examples/ssr_router/src/bin/ssr_router_server.rs
delete mode 100644 examples/ssr_router/src/lib.rs
delete mode 100644 examples/ssr_router/tests/e2e.rs
diff --git a/Cargo.lock b/Cargo.lock
index b9efd21f9a4..c79f7d0552b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,31 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "actix"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b"
+dependencies = [
+ "actix-macros",
+ "actix-rt",
+ "actix_derive",
+ "bitflags 2.11.0",
+ "bytes",
+ "crossbeam-channel",
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+ "log",
+ "once_cell",
+ "parking_lot",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "tokio-util",
+]
+
[[package]]
name = "actix-codec"
version = "0.5.2"
@@ -185,6 +210,45 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "actix_derive"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "actix_ssr_router"
+version = "0.1.0"
+dependencies = [
+ "actix",
+ "clap",
+ "env_logger",
+ "function_router",
+ "futures 0.3.32",
+ "getrandom 0.3.4",
+ "gloo",
+ "jemallocator",
+ "rand 0.9.2",
+ "serde",
+ "serde_json",
+ "ssr-e2e-harness",
+ "tokio",
+ "tracing-subscriber",
+ "tracing-web",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test",
+ "web-sys",
+ "yew",
+ "yew-link",
+ "yew-router",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -413,6 +477,38 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "axum_ssr_router"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "clap",
+ "env_logger",
+ "function_router",
+ "futures 0.3.32",
+ "getrandom 0.3.4",
+ "gloo",
+ "hyper",
+ "hyper-util",
+ "jemallocator",
+ "rand 0.9.2",
+ "serde",
+ "serde_json",
+ "ssr-e2e-harness",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing-subscriber",
+ "tracing-web",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test",
+ "web-sys",
+ "yew",
+ "yew-link",
+ "yew-router",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -900,6 +996,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -3617,38 +3728,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "ssr_router"
-version = "0.1.0"
-dependencies = [
- "axum",
- "clap",
- "env_logger",
- "function_router",
- "futures 0.3.32",
- "getrandom 0.3.4",
- "gloo",
- "hyper",
- "hyper-util",
- "jemallocator",
- "rand 0.9.2",
- "serde",
- "serde_json",
- "ssr-e2e-harness",
- "tokio",
- "tower",
- "tower-http",
- "tracing-subscriber",
- "tracing-web",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasm-bindgen-test",
- "web-sys",
- "yew",
- "yew-link",
- "yew-router",
-]
-
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
diff --git a/examples/ssr_router/Cargo.toml b/examples/ssr_router/Cargo.toml
deleted file mode 100644
index b250439cbb5..00000000000
--- a/examples/ssr_router/Cargo.toml
+++ /dev/null
@@ -1,56 +0,0 @@
-[package]
-name = "ssr_router"
-version = "0.1.0"
-edition = "2021"
-
-[[bin]]
-name = "ssr_router_hydrate"
-required-features = ["hydration"]
-
-[[bin]]
-name = "ssr_router_server"
-required-features = ["ssr"]
-
-[dependencies]
-yew = { path = "../../packages/yew" }
-yew-link = { path = "../../packages/yew-link" }
-yew-router = { path = "../../packages/yew-router" }
-function_router = { path = "../function_router" }
-rand = { workspace = true }
-getrandom = { workspace = true }
-serde = { workspace = true, features = ["derive"] }
-serde_json = { workspace = true }
-futures = { workspace = true, features = ["std"] }
-hyper-util = "0.1.20"
-
-[target.'cfg(target_arch = "wasm32")'.dependencies]
-wasm-bindgen-futures.workspace = true
-tracing-web.workspace = true
-tracing-subscriber.workspace = true
-
-[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] }
-axum = { workspace = true }
-tower = { version = "0.5", features = ["make"] }
-tower-http = { version = "0.6", features = ["fs", "cors"] }
-env_logger = "0.11"
-clap = { workspace = true }
-hyper = { version = "1.4", features = ["server", "http1"] }
-
-[target.'cfg(unix)'.dependencies]
-jemallocator = "0.5"
-
-[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
-wasm-bindgen-test.workspace = true
-wasm-bindgen.workspace = true
-web-sys = { workspace = true }
-gloo = { workspace = true }
-ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" }
-
-[dev-dependencies]
-yew = { path = "../../packages/yew", features = ["hydration", "test"] }
-yew-link = { path = "../../packages/yew-link", features = ["hydration"] }
-
-[features]
-ssr = ["yew/ssr", "yew-link/ssr", "yew-link/axum"]
-hydration = ["yew/hydration", "yew-link/hydration"]
diff --git a/examples/ssr_router/README.md b/examples/ssr_router/README.md
deleted file mode 100644
index 7a9ccebe364..00000000000
--- a/examples/ssr_router/README.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# SSR Router Example
-
-This example is the same as the function router example, but with
-server-side rendering and hydration support. It reuses the same codebase
-of the function router example.
-
-# How to run this example
-
-1. Build Hydration Bundle
-
-`trunk build`
-
-2. Run the server
-
-`cargo run --features=ssr --bin ssr_router_server -- --dir dist`
diff --git a/examples/ssr_router/index.html b/examples/ssr_router/index.html
deleted file mode 100644
index 98dfce4afeb..00000000000
--- a/examples/ssr_router/index.html
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
- Yew SSR Router
-
-
-
-
-
-
diff --git a/examples/ssr_router/src/bin/ssr_router_hydrate.rs b/examples/ssr_router/src/bin/ssr_router_hydrate.rs
deleted file mode 100644
index 15fd7fbb162..00000000000
--- a/examples/ssr_router/src/bin/ssr_router_hydrate.rs
+++ /dev/null
@@ -1,18 +0,0 @@
-use ssr_router::{App, AppProps, LINK_ENDPOINT};
-
-fn main() {
- #[cfg(target_arch = "wasm32")]
- {
- let fmt_layer = tracing_subscriber::fmt::layer()
- .with_ansi(false) // Only partially supported across browsers
- .without_time() // std::time is not available in browsers
- .with_writer(tracing_web::MakeWebConsoleWriter::new())
- .with_filter(tracing_subscriber::filter::LevelFilter::TRACE);
- use tracing_subscriber::prelude::*;
- tracing_subscriber::registry().with(fmt_layer).init();
- }
- yew::Renderer::::with_props(AppProps {
- endpoint: LINK_ENDPOINT.into(),
- })
- .hydrate();
-}
diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/ssr_router/src/bin/ssr_router_server.rs
deleted file mode 100644
index e0915821437..00000000000
--- a/examples/ssr_router/src/bin/ssr_router_server.rs
+++ /dev/null
@@ -1,196 +0,0 @@
-use std::collections::HashMap;
-use std::convert::Infallible;
-use std::future::Future;
-use std::net::SocketAddr;
-use std::path::PathBuf;
-
-use axum::body::Body;
-use axum::extract::{Query, Request, State};
-use axum::handler::HandlerWithoutStateExt;
-use axum::http::Uri;
-use axum::response::IntoResponse;
-use axum::routing::{get, post};
-use axum::Router;
-use clap::Parser;
-use function_router::{route_meta, Route};
-use futures::stream::{self, StreamExt};
-use hyper::body::Incoming;
-use hyper_util::rt::TokioIo;
-use hyper_util::server;
-use ssr_router::{
- LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT,
-};
-use tokio::net::TcpListener;
-use tower::Service;
-use tower_http::cors::CorsLayer;
-use tower_http::services::ServeDir;
-use yew::platform::Runtime;
-use yew_link::axum::linked_state_handler;
-use yew_link::{Resolver, ResolverProp};
-use yew_router::prelude::Routable;
-
-// We use jemalloc as it produces better performance.
-#[cfg(unix)]
-#[global_allocator]
-static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
-
-/// A basic example
-#[derive(Parser, Debug)]
-struct Opt {
- /// the "dist" created by trunk directory to be served for hydration.
- #[clap(short, long)]
- dir: PathBuf,
-}
-
-fn head_tags_for(path: &str) -> String {
- let route = Route::recognize(path).unwrap_or(Route::NotFound);
- let (title, description) = route_meta(&route);
- format!(
- "{title} | Yew SSR Router "
- )
-}
-
-#[derive(Clone)]
-struct AppState {
- index_html_before: String,
- index_html_after: String,
- resolver: ResolverProp,
-}
-
-async fn render(
- url: Uri,
- Query(queries): Query>,
- State(state): State,
-) -> impl IntoResponse {
- let path = url.path().to_owned();
-
- // Inject route-specific tags before , outside of Yew rendering.
- let before = state
- .index_html_before
- .replace("", &format!("{}", head_tags_for(&path)));
- let resolver = state.resolver.clone();
-
- let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps {
- url: path.into(),
- queries,
- resolver,
- });
-
- Body::from_stream(
- stream::once(async move { before })
- .chain(renderer.render_stream())
- .chain(stream::once(async move { state.index_html_after }))
- .map(Result::<_, Infallible>::Ok),
- )
-}
-
-// An executor to process requests on the Yew runtime.
-//
-// By spawning requests on the Yew runtime,
-// it processes request on the same thread as the rendering task.
-//
-// This increases performance in some environments (e.g.: in VM).
-#[derive(Clone, Default)]
-struct Executor {
- inner: Runtime,
-}
-
-impl hyper::rt::Executor for Executor
-where
- F: Future + Send + 'static,
-{
- fn execute(&self, fut: F) {
- self.inner.spawn_pinned(move || async move {
- fut.await;
- });
- }
-}
-
-#[tokio::main]
-async fn main() -> Result<(), Box> {
- let exec = Executor::default();
- env_logger::init();
- let opts = Opt::parse();
-
- let resolver_prop: ResolverProp = Resolver::new()
- .register_linked::(())
- .register_linked::(())
- .register_linked::(())
- .into();
- let arc_resolver = resolver_prop.0.clone();
-
- let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
- .await
- .expect("failed to read index.html");
-
- let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap();
- let mut index_html_before = index_html_before.to_owned();
- index_html_before.push_str("");
- let index_html_after = index_html_after.to_owned();
-
- let app_state = AppState {
- index_html_before,
- index_html_after,
- resolver: resolver_prop,
- };
-
- let app = Router::new()
- .route(
- LINK_ENDPOINT,
- post(linked_state_handler).with_state(arc_resolver),
- )
- .fallback_service(
- ServeDir::new(opts.dir)
- .append_index_html_on_directories(false)
- .fallback(get(render).with_state(app_state).into_service()),
- )
- .layer(CorsLayer::permissive());
-
- let addr: SocketAddr = ([0, 0, 0, 0], 8080).into();
- println!("You can view the website at: http://localhost:8080/");
-
- let listener = TcpListener::bind(addr).await?;
-
- // Continuously accept new connections.
- loop {
- // In this example we discard the remote address. See `fn serve_with_connect_info` for how
- // to expose that.
- let (socket, _remote_addr) = listener.accept().await.unwrap();
-
- // We don't need to call `poll_ready` because `Router` is always ready.
- let tower_service = app.clone();
-
- let exec = exec.clone();
- // Spawn a task to handle the connection. That way we can handle multiple connections
- // concurrently.
- tokio::spawn(async move {
- // Hyper has its own `AsyncRead` and `AsyncWrite` traits and doesn't use tokio.
- // `TokioIo` converts between them.
- let socket = TokioIo::new(socket);
-
- // Hyper also has its own `Service` trait and doesn't use tower. We can use
- // `hyper::service::service_fn` to create a hyper `Service` that calls our app through
- // `tower::Service::call`.
- let hyper_service = hyper::service::service_fn(move |request: Request| {
- // We have to clone `tower_service` because hyper's `Service` uses `&self` whereas
- // tower's `Service` requires `&mut self`.
- //
- // We don't need to call `poll_ready` since `Router` is always ready.
- tower_service.clone().call(request)
- });
-
- // `server::conn::auto::Builder` supports both http1 and http2.
- //
- // `TokioExecutor` tells hyper to use `tokio::spawn` to spawn tasks.
- if let Err(err) = server::conn::auto::Builder::new(exec)
- // `serve_connection_with_upgrades` is required for websockets. If you don't need
- // that you can use `serve_connection` instead.
- .serve_connection_with_upgrades(socket, hyper_service)
- .await
- {
- eprintln!("failed to serve connection: {err:#}");
- }
- });
- }
-}
diff --git a/examples/ssr_router/src/lib.rs b/examples/ssr_router/src/lib.rs
deleted file mode 100644
index 3c20d42c984..00000000000
--- a/examples/ssr_router/src/lib.rs
+++ /dev/null
@@ -1,468 +0,0 @@
-use std::collections::HashMap;
-
-use function_router::content;
-#[cfg(not(target_arch = "wasm32"))]
-use function_router::Generated;
-use serde::{Deserialize, Serialize};
-use yew::prelude::*;
-use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp};
-use yew_router::prelude::*;
-
-pub const LINK_ENDPOINT: &str = "/api/link";
-
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub struct LinkedPost(pub content::Post);
-
-#[linked_state]
-impl LinkedState for LinkedPost {
- type Context = ();
- type Input = u32;
-
- async fn resolve(_ctx: &(), seed: &u32) -> Self {
- Self(content::Post::generate_from_seed(*seed))
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub struct LinkedAuthor(pub content::Author);
-
-#[linked_state]
-impl LinkedState for LinkedAuthor {
- type Context = ();
- type Input = u32;
-
- async fn resolve(_ctx: &(), seed: &u32) -> Self {
- Self(content::Author::generate_from_seed(*seed))
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-pub struct LinkedPostMeta(pub content::PostMeta);
-
-#[linked_state]
-impl LinkedState for LinkedPostMeta {
- type Context = ();
- type Input = u32;
-
- async fn resolve(_ctx: &(), seed: &u32) -> Self {
- Self(content::PostMeta::generate_from_seed(*seed))
- }
-}
-
-#[derive(Properties, PartialEq, Clone)]
-pub struct PostProps {
- pub id: u32,
-}
-
-#[component]
-pub fn PostPage(props: &PostProps) -> HtmlResult {
- let post = use_linked_state::(props.id)?.data();
- Ok(render_post(&post.0))
-}
-
-fn render_post(post: &content::Post) -> Html {
- use content::PostPart;
-
- let render_quote = |quote: &content::Quote| {
- html! {
-
-
-
-
-
-
-
-
-
classes={classes!("is-size-5")} to={function_router::Route::Author { id: quote.author.seed }}>
-
{ "e.author.name }
- >
-
{ "e.content }
-
-
-
- }
- };
-
- let render_section_hero = |section: &content::Section| {
- html! {
-
-
-
-
-
{ §ion.title }
-
-
-
- }
- };
-
- let render_section = |section: &content::Section, show_hero: bool| {
- let hero = if show_hero {
- render_section_hero(section)
- } else {
- html! {}
- };
- html! {
-
- { hero }
-
- for p in section.paragraphs.iter() {
-
{ p }
- }
-
-
- }
- };
-
- let view_content = {
- let mut show_hero = false;
- let parts: Vec = post
- .content
- .iter()
- .map(|part| match part {
- PostPart::Section(section) => {
- let html = render_section(section, show_hero);
- show_hero = true;
- html
- }
- PostPart::Quote(quote) => {
- show_hero = false;
- render_quote(quote)
- }
- })
- .collect();
- html! {
- {for parts}
- }
- };
-
- html! {
- <>
-
-
-
-
-
{ &post.meta.title }
-
- { "by " }
- classes={classes!("has-text-weight-semibold")} to={function_router::Route::Author { id: post.meta.author.seed }}>
- { &post.meta.author.name }
- >
-
-
- for kw in &post.meta.keywords {
- { kw }
- }
-
-
-
-
- { view_content }
- >
- }
-}
-
-#[derive(Properties, PartialEq, Clone)]
-pub struct AuthorProps {
- pub id: u32,
-}
-
-#[component]
-pub fn AuthorPage(props: &AuthorProps) -> HtmlResult {
- let author = use_linked_state::(props.id)?.data();
- Ok(render_author(&author.0))
-}
-
-fn render_author(author: &content::Author) -> Html {
- html! {
-
-
-
-
-
-
- { "Interests" }
-
- for tag in &author.keywords {
- { tag }
- }
-
-
-
-
-
-
-
-
-
-
-
-
{ "About me" }
-
- { "This author has chosen not to reveal anything about themselves" }
-
-
-
-
-
-
-
- }
-}
-
-#[derive(Properties, PartialEq, Clone)]
-struct CardProps {
- seed: u32,
-}
-
-#[component]
-fn LinkedPostCard(props: &CardProps) -> HtmlResult {
- let meta = use_linked_state::(props.seed)?.data();
- let meta = &meta.0;
- Ok(html! {
-
-
-
-
-
-
-
- classes={classes!("title", "is-block")} to={function_router::Route::Post { id: meta.seed }}>
- { &meta.title }
- >
- classes={classes!("subtitle", "is-block")} to={function_router::Route::Author { id: meta.author.seed }}>
- { &meta.author.name }
- >
-
-
- })
-}
-
-#[component]
-fn LinkedAuthorCard(props: &CardProps) -> HtmlResult {
- let author = use_linked_state::(props.seed)?.data();
- let author = &author.0;
- Ok(html! {
-
- })
-}
-
-const ITEMS_PER_PAGE: u32 = 10;
-const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE;
-
-#[component]
-fn LinkedPostList() -> Html {
- use function_router::components::pagination::{PageQuery, Pagination};
-
- let location = use_location().unwrap();
- let current_page = location.query::().map(|it| it.page).unwrap_or(1);
-
- let start_seed = (current_page - 1) * ITEMS_PER_PAGE;
- let half = ITEMS_PER_PAGE / 2;
-
- html! {
-
-
{ "Posts" }
-
{ "All of our quality writing in one place" }
-
-
-
- for offset in 0..half {
-
- {"Loading..."}
}}>
-
-
-
- }
-
-
-
-
- for offset in half..ITEMS_PER_PAGE {
-
- {"Loading..."}
}}>
-
-
-
- }
-
-
-
-
-
- }
-}
-
-#[component]
-fn LinkedAuthorList() -> Html {
- let seeds = use_state(|| {
- use rand::{distr, Rng};
- rand::rng()
- .sample_iter(distr::StandardUniform)
- .take(2)
- .collect::>()
- });
-
- let on_complete = {
- let seeds = seeds.clone();
- Callback::from(move |_| {
- use rand::{distr, Rng};
- seeds.set(
- rand::rng()
- .sample_iter(distr::StandardUniform)
- .take(2)
- .collect(),
- );
- })
- };
-
- html! {
-
-
-
-
-
{ "Authors" }
-
- { "Meet the definitely real people behind your favourite Yew content" }
-
-
-
-
-
- { "It wouldn't be fair " }
- { "(or possible :P)" }
- {" to list each and every author in alphabetical order."}
-
- { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" }
-
-
-
- for seed in seeds.iter().copied() {
-
-
- }
-
-
-
-
- }
-}
-
-fn switch(routes: function_router::Route) -> Html {
- use function_router::Route;
-
- match routes {
- Route::Post { id } => html! {
- {"Loading post..."}
}}>
-
-
- },
- Route::Author { id } => html! {
- {"Loading author..."} }}>
-
-
- },
- Route::Posts => html! { },
- Route::Authors => html! { },
- Route::Home => html! { },
- Route::NotFound => html! { },
- }
-}
-
-#[derive(Properties, PartialEq)]
-pub struct AppProps {
- pub endpoint: AttrValue,
-}
-
-#[component]
-pub fn App(props: &AppProps) -> Html {
- html! {
-
-
-
-
- render={switch} />
-
-
-
-
- }
-}
-
-#[derive(Properties, PartialEq, Debug)]
-pub struct ServerAppProps {
- pub url: AttrValue,
- pub queries: HashMap,
- pub resolver: ResolverProp,
-}
-
-#[component]
-pub fn ServerApp(props: &ServerAppProps) -> Html {
- use yew_router::history::{AnyHistory, History, MemoryHistory};
-
- let history = AnyHistory::from(MemoryHistory::new());
- history
- .push_with_query(&*props.url, &props.queries)
- .unwrap();
-
- html! {
-
-
-
-
- render={switch} />
-
-
-
-
- }
-}
diff --git a/examples/ssr_router/tests/e2e.rs b/examples/ssr_router/tests/e2e.rs
deleted file mode 100644
index 39887013cf1..00000000000
--- a/examples/ssr_router/tests/e2e.rs
+++ /dev/null
@@ -1,199 +0,0 @@
-#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
-
-use gloo::utils::document;
-use ssr_e2e_harness::{
- clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page,
- wait_for,
-};
-use ssr_router::{App, AppProps, LINK_ENDPOINT};
-use wasm_bindgen::JsCast;
-use wasm_bindgen_test::*;
-use yew::Renderer;
-
-wasm_bindgen_test_configure!(run_in_browser);
-
-const SERVER_BASE: &str = "http://127.0.0.1:8080";
-
-fn endpoint() -> String {
- format!("{SERVER_BASE}{LINK_ENDPOINT}")
-}
-
-fn make_renderer() -> Renderer {
- Renderer::::with_root_and_props(
- output_element(),
- AppProps {
- endpoint: endpoint().into(),
- },
- )
-}
-
-fn get_title_text() -> Option {
- document()
- .query_selector("h1.title")
- .ok()
- .flatten()
- .map(|el| el.text_content().unwrap_or_default())
-}
-
-fn post_body_text() -> String {
- output_element()
- .query_selector(".section.container")
- .ok()
- .flatten()
- .map(|el| el.text_content().unwrap_or_default())
- .unwrap_or_default()
-}
-
-fn extract_text_from_html(html: &str, selector: &str) -> Option {
- let container = document().create_element("div").unwrap();
- container.set_inner_html(html);
- container
- .query_selector(selector)
- .ok()
- .flatten()
- .and_then(|el| el.text_content())
-}
-
-#[wasm_bindgen_test]
-async fn ssr_hydration_and_client_navigation() {
- // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link --
-
- let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await;
- let ssr_title = extract_text_from_html(&ssr_html, "h1.title")
- .expect("SSR HTML for /posts/0 should contain h1.title");
- let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default();
-
- clear_resource_timings();
-
- output_element().set_inner_html(&ssr_html);
- ssr_e2e_harness::push_route("/posts/0");
- let app = make_renderer().hydrate();
-
- wait_for(
- || {
- let html = output_element().inner_html();
- html.contains("") && !html.contains("Loading post...")
- },
- 5000,
- "post page content after SSR hydration",
- )
- .await;
-
- let link_fetches = resource_request_count(LINK_ENDPOINT);
- let title = get_title_text();
-
- assert_eq!(
- link_fetches, 0,
- "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}"
- );
- let title = title.expect("h1.title should be present on the SSR post page");
- assert!(!title.is_empty(), "SSR post title should not be empty");
-
- // -- Part 2: Navigate to /posts within the same app, then to /posts/0 --
-
- yew::scheduler::flush().await;
-
- clear_resource_timings();
-
- let posts_link: web_sys::HtmlElement = output_element()
- .query_selector("a.navbar-item[href='/posts']")
- .unwrap()
- .expect("Posts navbar link should exist")
- .dyn_into()
- .unwrap();
- posts_link.click();
- yew::scheduler::flush().await;
-
- wait_for(
- || {
- document()
- .query_selector("a.title.is-block")
- .ok()
- .flatten()
- .is_some()
- && get_title_text().as_deref() == Some("Posts")
- },
- 15000,
- "posts list after client-side navigation to /posts",
- )
- .await;
-
- clear_resource_timings();
-
- wait_for(
- || {
- output_element()
- .query_selector("a.title.is-block[href='/posts/0']")
- .ok()
- .flatten()
- .is_some()
- },
- 15000,
- "post 0 card link on posts list",
- )
- .await;
-
- let post_link: web_sys::HtmlElement = output_element()
- .query_selector("a.title.is-block[href='/posts/0']")
- .unwrap()
- .unwrap()
- .dyn_into()
- .unwrap();
- post_link.click();
- yew::scheduler::flush().await;
-
- wait_for(
- || {
- document()
- .query_selector("h2.subtitle")
- .ok()
- .flatten()
- .map(|el| el.text_content().unwrap_or_default())
- .is_some_and(|text| text.starts_with("by "))
- },
- 15000,
- "post page content after client-side navigation to /posts/0",
- )
- .await;
-
- // -- Part 3: Verify fetch happened and content matches SSR --
-
- let nav_link_fetches = resource_request_count(LINK_ENDPOINT);
- let nav_title = get_title_text();
- let nav_body = post_body_text();
-
- assert!(
- nav_link_fetches >= 1,
- "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \
- got {nav_link_fetches}"
- );
-
- let nav_title = nav_title.expect("h1.title should be present after client-side navigation");
- assert_eq!(
- ssr_title, nav_title,
- "post title should match between SSR and client-side navigation"
- );
- assert_eq!(
- ssr_body, nav_body,
- "post body should match between SSR and client-side navigation"
- );
-
- app.destroy();
- yew::scheduler::flush().await;
-}
-
-#[wasm_bindgen_test]
-async fn hydrate_home() {
- setup_ssr_page(SERVER_BASE, "/").await;
- let app = make_renderer().hydrate();
-
- wait_for(
- || output_element().inner_html().contains("Welcome"),
- 5000,
- "home page content after hydration",
- )
- .await;
-
- app.destroy();
- yew::scheduler::flush().await;
-}
diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml
index 3fd2f597a20..2b7efebc6c0 100644
--- a/packages/yew-link/Cargo.toml
+++ b/packages/yew-link/Cargo.toml
@@ -26,8 +26,9 @@ actix-web = { version = "4.13", optional = true }
default = []
ssr = ["yew/ssr"]
hydration = ["yew/hydration"]
-axum = ["dep:axum"]
-actix = ["dep:actix-web"]
+axum = ["dep:axum", "services"]
+actix = ["dep:actix-web", "services"]
+services = []
[lints]
workspace = true
diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs
index 4e09dd807b7..855fe3d6763 100644
--- a/packages/yew-link/src/lib.rs
+++ b/packages/yew-link/src/lib.rs
@@ -290,7 +290,7 @@ impl Eq for CacheKey {}
fn eq_inputs(a: &dyn Any, b: &dyn Any) -> bool {
a.downcast_ref::()
.zip(b.downcast_ref::())
- .map_or(false, |(a, b)| a == b)
+ .is_some_and(|(a, b)| a == b)
}
#[cfg(target_arch = "wasm32")]
@@ -679,8 +679,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult
Date: Mon, 6 Apr 2026 19:51:33 +0200
Subject: [PATCH 06/16] chore: #4113 add a test for actix_ssr_router using
yew_link
---
Cargo.lock | 54 +-
examples/actix_ssr_router/Cargo.toml | 53 ++
examples/actix_ssr_router/README.md | 14 +
examples/actix_ssr_router/index.html | 17 +
.../src/bin/ssr_router_hydrate.rs | 18 +
.../src/bin/ssr_router_server.rs | 112 +++++
examples/actix_ssr_router/src/lib.rs | 468 ++++++++++++++++++
examples/actix_ssr_router/tests/e2e.rs | 199 ++++++++
examples/axum_ssr_router/Cargo.toml | 56 +++
examples/axum_ssr_router/README.md | 15 +
examples/axum_ssr_router/index.html | 17 +
.../src/bin/ssr_router_hydrate.rs | 18 +
.../src/bin/ssr_router_server.rs | 196 ++++++++
examples/axum_ssr_router/src/lib.rs | 468 ++++++++++++++++++
examples/axum_ssr_router/tests/e2e.rs | 199 ++++++++
15 files changed, 1852 insertions(+), 52 deletions(-)
create mode 100644 examples/actix_ssr_router/Cargo.toml
create mode 100644 examples/actix_ssr_router/README.md
create mode 100644 examples/actix_ssr_router/index.html
create mode 100644 examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs
create mode 100644 examples/actix_ssr_router/src/bin/ssr_router_server.rs
create mode 100644 examples/actix_ssr_router/src/lib.rs
create mode 100644 examples/actix_ssr_router/tests/e2e.rs
create mode 100644 examples/axum_ssr_router/Cargo.toml
create mode 100644 examples/axum_ssr_router/README.md
create mode 100644 examples/axum_ssr_router/index.html
create mode 100644 examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs
create mode 100644 examples/axum_ssr_router/src/bin/ssr_router_server.rs
create mode 100644 examples/axum_ssr_router/src/lib.rs
create mode 100644 examples/axum_ssr_router/tests/e2e.rs
diff --git a/Cargo.lock b/Cargo.lock
index c79f7d0552b..2b1f58c27d9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,31 +2,6 @@
# It is not intended for manual editing.
version = 4
-[[package]]
-name = "actix"
-version = "0.13.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b"
-dependencies = [
- "actix-macros",
- "actix-rt",
- "actix_derive",
- "bitflags 2.11.0",
- "bytes",
- "crossbeam-channel",
- "futures-core",
- "futures-sink",
- "futures-task",
- "futures-util",
- "log",
- "once_cell",
- "parking_lot",
- "pin-project-lite",
- "smallvec",
- "tokio",
- "tokio-util",
-]
-
[[package]]
name = "actix-codec"
version = "0.5.2"
@@ -210,22 +185,12 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "actix_derive"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "actix_ssr_router"
version = "0.1.0"
dependencies = [
- "actix",
+ "actix-web",
+ "bytes",
"clap",
"env_logger",
"function_router",
@@ -996,21 +961,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
-[[package]]
-name = "crossbeam-channel"
-version = "0.5.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
-
[[package]]
name = "crypto-common"
version = "0.1.7"
diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml
new file mode 100644
index 00000000000..7b4759f4640
--- /dev/null
+++ b/examples/actix_ssr_router/Cargo.toml
@@ -0,0 +1,53 @@
+[package]
+name = "actix_ssr_router"
+version = "0.1.0"
+edition = "2021"
+
+[[bin]]
+name = "ssr_router_hydrate"
+required-features = ["hydration"]
+
+[[bin]]
+name = "ssr_router_server"
+required-features = ["ssr"]
+
+[dependencies]
+yew = { path = "../../packages/yew" }
+yew-link = { path = "../../packages/yew-link" }
+yew-router = { path = "../../packages/yew-router" }
+function_router = { path = "../function_router" }
+rand = { workspace = true }
+getrandom = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+futures = { workspace = true, features = ["std"] }
+bytes = "1.11.1"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen-futures.workspace = true
+tracing-web.workspace = true
+tracing-subscriber.workspace = true
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] }
+env_logger = "0.11"
+clap = { workspace = true }
+actix-web = "4.13.0"
+
+[target.'cfg(unix)'.dependencies]
+jemallocator = "0.5"
+
+[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
+wasm-bindgen-test.workspace = true
+wasm-bindgen.workspace = true
+web-sys = { workspace = true }
+gloo = { workspace = true }
+ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" }
+
+[dev-dependencies]
+yew = { path = "../../packages/yew", features = ["hydration", "test"] }
+yew-link = { path = "../../packages/yew-link", features = ["hydration"] }
+
+[features]
+ssr = ["yew/ssr", "yew-link/ssr", "yew-link/actix"]
+hydration = ["yew/hydration", "yew-link/hydration"]
diff --git a/examples/actix_ssr_router/README.md b/examples/actix_ssr_router/README.md
new file mode 100644
index 00000000000..0621f345acb
--- /dev/null
+++ b/examples/actix_ssr_router/README.md
@@ -0,0 +1,14 @@
+# SSR Router Example
+
+This example is the same as the `axum_ssr_router`, except the
+server side is served with `actix_web` instead of axum.
+
+# How to run this example
+
+1. Build Hydration Bundle
+
+`trunk build`
+
+2. Run the server
+
+`cargo run --features=ssr --bin ssr_router_server -- --dir dist`
diff --git a/examples/actix_ssr_router/index.html b/examples/actix_ssr_router/index.html
new file mode 100644
index 00000000000..98dfce4afeb
--- /dev/null
+++ b/examples/actix_ssr_router/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ Yew SSR Router
+
+
+
+
+
+
diff --git a/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs b/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs
new file mode 100644
index 00000000000..38761324508
--- /dev/null
+++ b/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs
@@ -0,0 +1,18 @@
+use actix_ssr_router::{App, AppProps, LINK_ENDPOINT};
+
+fn main() {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let fmt_layer = tracing_subscriber::fmt::layer()
+ .with_ansi(false) // Only partially supported across browsers
+ .without_time() // std::time is not available in browsers
+ .with_writer(tracing_web::MakeWebConsoleWriter::new())
+ .with_filter(tracing_subscriber::filter::LevelFilter::TRACE);
+ use tracing_subscriber::prelude::*;
+ tracing_subscriber::registry().with(fmt_layer).init();
+ }
+ yew::Renderer::::with_props(AppProps {
+ endpoint: LINK_ENDPOINT.into(),
+ })
+ .hydrate();
+}
diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
new file mode 100644
index 00000000000..e18f7db05a2
--- /dev/null
+++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
@@ -0,0 +1,112 @@
+use std::collections::HashMap;
+use std::io::Result as IoResult;
+use std::path::PathBuf;
+
+use actix_ssr_router::{
+ LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT,
+};
+use actix_web::http::Uri;
+use actix_web::web::{get, post, Data, Query};
+use actix_web::{App, Error, HttpResponse, HttpServer};
+use bytes::Bytes;
+use clap::Parser;
+use function_router::{route_meta, Route};
+use futures::stream::{self, StreamExt};
+use yew_link::actix_web::linked_state_handler;
+use yew_link::{Resolver, ResolverProp};
+use yew_router::prelude::Routable;
+
+// We use jemalloc as it produces better performance.
+#[cfg(unix)]
+#[global_allocator]
+static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
+
+/// A basic example
+#[derive(Parser, Debug)]
+struct Opt {
+ /// the "dist" created by trunk directory to be served for hydration.
+ #[clap(short, long)]
+ dir: PathBuf,
+}
+
+fn head_tags_for(path: &str) -> String {
+ let route = Route::recognize(path).unwrap_or(Route::NotFound);
+ let (title, description) = route_meta(&route);
+ format!(
+ "{title} | Yew SSR Router "
+ )
+}
+
+#[derive(Clone)]
+struct AppState {
+ index_html_before: String,
+ index_html_after: String,
+ resolver: ResolverProp,
+}
+
+async fn render(
+ url: Uri,
+ Query(queries): Query>,
+ state: Data,
+) -> HttpResponse {
+ let state = state.into_inner();
+
+ let path = url.path().to_owned();
+
+ // Inject route-specific tags before , outside of Yew rendering.
+ let before = state
+ .index_html_before
+ .replace("", &format!("{}", head_tags_for(&path)));
+ let resolver = state.resolver.clone();
+
+ let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps {
+ url: path.into(),
+ queries,
+ resolver,
+ });
+
+ HttpResponse::Ok().streaming(
+ stream::once(async move { Bytes::from(before) })
+ .chain(renderer.render_stream().map(Bytes::from))
+ .chain(stream::once(async move {
+ Bytes::from(state.index_html_after.clone())
+ }))
+ .map(Ok::),
+ )
+}
+
+#[actix_web::main]
+async fn main() -> IoResult<()> {
+ env_logger::init();
+ let opts = Opt::parse();
+
+ let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
+ .await
+ .expect("failed to read index.html");
+
+ let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap();
+ let mut index_html_before = index_html_before.to_owned();
+ index_html_before.push_str("");
+ let index_html_after = index_html_after.to_owned();
+
+ let app_state = Data::new(AppState {
+ index_html_before,
+ index_html_after,
+ resolver: Resolver::new()
+ .register_linked::(())
+ .register_linked::(())
+ .register_linked::(())
+ .into(),
+ });
+
+ HttpServer::new(move || {
+ App::new()
+ .app_data(app_state.clone())
+ .route(LINK_ENDPOINT, post().to(linked_state_handler))
+ .default_service(get().to(render))
+ })
+ .bind(("0.0.0.0", 8080))?
+ .run()
+ .await
+}
diff --git a/examples/actix_ssr_router/src/lib.rs b/examples/actix_ssr_router/src/lib.rs
new file mode 100644
index 00000000000..3c20d42c984
--- /dev/null
+++ b/examples/actix_ssr_router/src/lib.rs
@@ -0,0 +1,468 @@
+use std::collections::HashMap;
+
+use function_router::content;
+#[cfg(not(target_arch = "wasm32"))]
+use function_router::Generated;
+use serde::{Deserialize, Serialize};
+use yew::prelude::*;
+use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp};
+use yew_router::prelude::*;
+
+pub const LINK_ENDPOINT: &str = "/api/link";
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LinkedPost(pub content::Post);
+
+#[linked_state]
+impl LinkedState for LinkedPost {
+ type Context = ();
+ type Input = u32;
+
+ async fn resolve(_ctx: &(), seed: &u32) -> Self {
+ Self(content::Post::generate_from_seed(*seed))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LinkedAuthor(pub content::Author);
+
+#[linked_state]
+impl LinkedState for LinkedAuthor {
+ type Context = ();
+ type Input = u32;
+
+ async fn resolve(_ctx: &(), seed: &u32) -> Self {
+ Self(content::Author::generate_from_seed(*seed))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LinkedPostMeta(pub content::PostMeta);
+
+#[linked_state]
+impl LinkedState for LinkedPostMeta {
+ type Context = ();
+ type Input = u32;
+
+ async fn resolve(_ctx: &(), seed: &u32) -> Self {
+ Self(content::PostMeta::generate_from_seed(*seed))
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct PostProps {
+ pub id: u32,
+}
+
+#[component]
+pub fn PostPage(props: &PostProps) -> HtmlResult {
+ let post = use_linked_state::(props.id)?.data();
+ Ok(render_post(&post.0))
+}
+
+fn render_post(post: &content::Post) -> Html {
+ use content::PostPart;
+
+ let render_quote = |quote: &content::Quote| {
+ html! {
+
+
+
+
+
+
+
+
+
classes={classes!("is-size-5")} to={function_router::Route::Author { id: quote.author.seed }}>
+
{ "e.author.name }
+ >
+
{ "e.content }
+
+
+
+ }
+ };
+
+ let render_section_hero = |section: &content::Section| {
+ html! {
+
+
+
+
+
{ §ion.title }
+
+
+
+ }
+ };
+
+ let render_section = |section: &content::Section, show_hero: bool| {
+ let hero = if show_hero {
+ render_section_hero(section)
+ } else {
+ html! {}
+ };
+ html! {
+
+ { hero }
+
+ for p in section.paragraphs.iter() {
+
{ p }
+ }
+
+
+ }
+ };
+
+ let view_content = {
+ let mut show_hero = false;
+ let parts: Vec = post
+ .content
+ .iter()
+ .map(|part| match part {
+ PostPart::Section(section) => {
+ let html = render_section(section, show_hero);
+ show_hero = true;
+ html
+ }
+ PostPart::Quote(quote) => {
+ show_hero = false;
+ render_quote(quote)
+ }
+ })
+ .collect();
+ html! {
+ {for parts}
+ }
+ };
+
+ html! {
+ <>
+
+
+
+
+
{ &post.meta.title }
+
+ { "by " }
+ classes={classes!("has-text-weight-semibold")} to={function_router::Route::Author { id: post.meta.author.seed }}>
+ { &post.meta.author.name }
+ >
+
+
+ for kw in &post.meta.keywords {
+ { kw }
+ }
+
+
+
+
+ { view_content }
+ >
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct AuthorProps {
+ pub id: u32,
+}
+
+#[component]
+pub fn AuthorPage(props: &AuthorProps) -> HtmlResult {
+ let author = use_linked_state::(props.id)?.data();
+ Ok(render_author(&author.0))
+}
+
+fn render_author(author: &content::Author) -> Html {
+ html! {
+
+
+
+
+
+
+ { "Interests" }
+
+ for tag in &author.keywords {
+ { tag }
+ }
+
+
+
+
+
+
+
+
+
+
+
+
{ "About me" }
+
+ { "This author has chosen not to reveal anything about themselves" }
+
+
+
+
+
+
+
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+struct CardProps {
+ seed: u32,
+}
+
+#[component]
+fn LinkedPostCard(props: &CardProps) -> HtmlResult {
+ let meta = use_linked_state::(props.seed)?.data();
+ let meta = &meta.0;
+ Ok(html! {
+
+
+
+
+
+
+
+ classes={classes!("title", "is-block")} to={function_router::Route::Post { id: meta.seed }}>
+ { &meta.title }
+ >
+ classes={classes!("subtitle", "is-block")} to={function_router::Route::Author { id: meta.author.seed }}>
+ { &meta.author.name }
+ >
+
+
+ })
+}
+
+#[component]
+fn LinkedAuthorCard(props: &CardProps) -> HtmlResult {
+ let author = use_linked_state::(props.seed)?.data();
+ let author = &author.0;
+ Ok(html! {
+
+ })
+}
+
+const ITEMS_PER_PAGE: u32 = 10;
+const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE;
+
+#[component]
+fn LinkedPostList() -> Html {
+ use function_router::components::pagination::{PageQuery, Pagination};
+
+ let location = use_location().unwrap();
+ let current_page = location.query::().map(|it| it.page).unwrap_or(1);
+
+ let start_seed = (current_page - 1) * ITEMS_PER_PAGE;
+ let half = ITEMS_PER_PAGE / 2;
+
+ html! {
+
+
{ "Posts" }
+
{ "All of our quality writing in one place" }
+
+
+
+ for offset in 0..half {
+
+ {"Loading..."}
}}>
+
+
+
+ }
+
+
+
+
+ for offset in half..ITEMS_PER_PAGE {
+
+ {"Loading..."}
}}>
+
+
+
+ }
+
+
+
+
+
+ }
+}
+
+#[component]
+fn LinkedAuthorList() -> Html {
+ let seeds = use_state(|| {
+ use rand::{distr, Rng};
+ rand::rng()
+ .sample_iter(distr::StandardUniform)
+ .take(2)
+ .collect::>()
+ });
+
+ let on_complete = {
+ let seeds = seeds.clone();
+ Callback::from(move |_| {
+ use rand::{distr, Rng};
+ seeds.set(
+ rand::rng()
+ .sample_iter(distr::StandardUniform)
+ .take(2)
+ .collect(),
+ );
+ })
+ };
+
+ html! {
+
+
+
+
+
{ "Authors" }
+
+ { "Meet the definitely real people behind your favourite Yew content" }
+
+
+
+
+
+ { "It wouldn't be fair " }
+ { "(or possible :P)" }
+ {" to list each and every author in alphabetical order."}
+
+ { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" }
+
+
+
+ for seed in seeds.iter().copied() {
+
+
+ }
+
+
+
+
+ }
+}
+
+fn switch(routes: function_router::Route) -> Html {
+ use function_router::Route;
+
+ match routes {
+ Route::Post { id } => html! {
+ {"Loading post..."} }}>
+
+
+ },
+ Route::Author { id } => html! {
+ {"Loading author..."} }}>
+
+
+ },
+ Route::Posts => html! { },
+ Route::Authors => html! { },
+ Route::Home => html! { },
+ Route::NotFound => html! { },
+ }
+}
+
+#[derive(Properties, PartialEq)]
+pub struct AppProps {
+ pub endpoint: AttrValue,
+}
+
+#[component]
+pub fn App(props: &AppProps) -> Html {
+ html! {
+
+
+
+
+ render={switch} />
+
+
+
+
+ }
+}
+
+#[derive(Properties, PartialEq, Debug)]
+pub struct ServerAppProps {
+ pub url: AttrValue,
+ pub queries: HashMap,
+ pub resolver: ResolverProp,
+}
+
+#[component]
+pub fn ServerApp(props: &ServerAppProps) -> Html {
+ use yew_router::history::{AnyHistory, History, MemoryHistory};
+
+ let history = AnyHistory::from(MemoryHistory::new());
+ history
+ .push_with_query(&*props.url, &props.queries)
+ .unwrap();
+
+ html! {
+
+
+
+
+ render={switch} />
+
+
+
+
+ }
+}
diff --git a/examples/actix_ssr_router/tests/e2e.rs b/examples/actix_ssr_router/tests/e2e.rs
new file mode 100644
index 00000000000..39887013cf1
--- /dev/null
+++ b/examples/actix_ssr_router/tests/e2e.rs
@@ -0,0 +1,199 @@
+#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
+
+use gloo::utils::document;
+use ssr_e2e_harness::{
+ clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page,
+ wait_for,
+};
+use ssr_router::{App, AppProps, LINK_ENDPOINT};
+use wasm_bindgen::JsCast;
+use wasm_bindgen_test::*;
+use yew::Renderer;
+
+wasm_bindgen_test_configure!(run_in_browser);
+
+const SERVER_BASE: &str = "http://127.0.0.1:8080";
+
+fn endpoint() -> String {
+ format!("{SERVER_BASE}{LINK_ENDPOINT}")
+}
+
+fn make_renderer() -> Renderer {
+ Renderer::::with_root_and_props(
+ output_element(),
+ AppProps {
+ endpoint: endpoint().into(),
+ },
+ )
+}
+
+fn get_title_text() -> Option {
+ document()
+ .query_selector("h1.title")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+}
+
+fn post_body_text() -> String {
+ output_element()
+ .query_selector(".section.container")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+ .unwrap_or_default()
+}
+
+fn extract_text_from_html(html: &str, selector: &str) -> Option {
+ let container = document().create_element("div").unwrap();
+ container.set_inner_html(html);
+ container
+ .query_selector(selector)
+ .ok()
+ .flatten()
+ .and_then(|el| el.text_content())
+}
+
+#[wasm_bindgen_test]
+async fn ssr_hydration_and_client_navigation() {
+ // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link --
+
+ let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await;
+ let ssr_title = extract_text_from_html(&ssr_html, "h1.title")
+ .expect("SSR HTML for /posts/0 should contain h1.title");
+ let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default();
+
+ clear_resource_timings();
+
+ output_element().set_inner_html(&ssr_html);
+ ssr_e2e_harness::push_route("/posts/0");
+ let app = make_renderer().hydrate();
+
+ wait_for(
+ || {
+ let html = output_element().inner_html();
+ html.contains("") && !html.contains("Loading post...")
+ },
+ 5000,
+ "post page content after SSR hydration",
+ )
+ .await;
+
+ let link_fetches = resource_request_count(LINK_ENDPOINT);
+ let title = get_title_text();
+
+ assert_eq!(
+ link_fetches, 0,
+ "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}"
+ );
+ let title = title.expect("h1.title should be present on the SSR post page");
+ assert!(!title.is_empty(), "SSR post title should not be empty");
+
+ // -- Part 2: Navigate to /posts within the same app, then to /posts/0 --
+
+ yew::scheduler::flush().await;
+
+ clear_resource_timings();
+
+ let posts_link: web_sys::HtmlElement = output_element()
+ .query_selector("a.navbar-item[href='/posts']")
+ .unwrap()
+ .expect("Posts navbar link should exist")
+ .dyn_into()
+ .unwrap();
+ posts_link.click();
+ yew::scheduler::flush().await;
+
+ wait_for(
+ || {
+ document()
+ .query_selector("a.title.is-block")
+ .ok()
+ .flatten()
+ .is_some()
+ && get_title_text().as_deref() == Some("Posts")
+ },
+ 15000,
+ "posts list after client-side navigation to /posts",
+ )
+ .await;
+
+ clear_resource_timings();
+
+ wait_for(
+ || {
+ output_element()
+ .query_selector("a.title.is-block[href='/posts/0']")
+ .ok()
+ .flatten()
+ .is_some()
+ },
+ 15000,
+ "post 0 card link on posts list",
+ )
+ .await;
+
+ let post_link: web_sys::HtmlElement = output_element()
+ .query_selector("a.title.is-block[href='/posts/0']")
+ .unwrap()
+ .unwrap()
+ .dyn_into()
+ .unwrap();
+ post_link.click();
+ yew::scheduler::flush().await;
+
+ wait_for(
+ || {
+ document()
+ .query_selector("h2.subtitle")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+ .is_some_and(|text| text.starts_with("by "))
+ },
+ 15000,
+ "post page content after client-side navigation to /posts/0",
+ )
+ .await;
+
+ // -- Part 3: Verify fetch happened and content matches SSR --
+
+ let nav_link_fetches = resource_request_count(LINK_ENDPOINT);
+ let nav_title = get_title_text();
+ let nav_body = post_body_text();
+
+ assert!(
+ nav_link_fetches >= 1,
+ "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \
+ got {nav_link_fetches}"
+ );
+
+ let nav_title = nav_title.expect("h1.title should be present after client-side navigation");
+ assert_eq!(
+ ssr_title, nav_title,
+ "post title should match between SSR and client-side navigation"
+ );
+ assert_eq!(
+ ssr_body, nav_body,
+ "post body should match between SSR and client-side navigation"
+ );
+
+ app.destroy();
+ yew::scheduler::flush().await;
+}
+
+#[wasm_bindgen_test]
+async fn hydrate_home() {
+ setup_ssr_page(SERVER_BASE, "/").await;
+ let app = make_renderer().hydrate();
+
+ wait_for(
+ || output_element().inner_html().contains("Welcome"),
+ 5000,
+ "home page content after hydration",
+ )
+ .await;
+
+ app.destroy();
+ yew::scheduler::flush().await;
+}
diff --git a/examples/axum_ssr_router/Cargo.toml b/examples/axum_ssr_router/Cargo.toml
new file mode 100644
index 00000000000..1fa92161f1a
--- /dev/null
+++ b/examples/axum_ssr_router/Cargo.toml
@@ -0,0 +1,56 @@
+[package]
+name = "axum_ssr_router"
+version = "0.1.0"
+edition = "2021"
+
+[[bin]]
+name = "ssr_router_hydrate"
+required-features = ["hydration"]
+
+[[bin]]
+name = "ssr_router_server"
+required-features = ["ssr"]
+
+[dependencies]
+yew = { path = "../../packages/yew" }
+yew-link = { path = "../../packages/yew-link" }
+yew-router = { path = "../../packages/yew-router" }
+function_router = { path = "../function_router" }
+rand = { workspace = true }
+getrandom = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+futures = { workspace = true, features = ["std"] }
+hyper-util = "0.1.20"
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen-futures.workspace = true
+tracing-web.workspace = true
+tracing-subscriber.workspace = true
+
+[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] }
+axum = { workspace = true }
+tower = { version = "0.5", features = ["make"] }
+tower-http = { version = "0.6", features = ["fs", "cors"] }
+env_logger = "0.11"
+clap = { workspace = true }
+hyper = { version = "1.4", features = ["server", "http1"] }
+
+[target.'cfg(unix)'.dependencies]
+jemallocator = "0.5"
+
+[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
+wasm-bindgen-test.workspace = true
+wasm-bindgen.workspace = true
+web-sys = { workspace = true }
+gloo = { workspace = true }
+ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" }
+
+[dev-dependencies]
+yew = { path = "../../packages/yew", features = ["hydration", "test"] }
+yew-link = { path = "../../packages/yew-link", features = ["hydration"] }
+
+[features]
+ssr = ["yew/ssr", "yew-link/ssr", "yew-link/axum"]
+hydration = ["yew/hydration", "yew-link/hydration"]
diff --git a/examples/axum_ssr_router/README.md b/examples/axum_ssr_router/README.md
new file mode 100644
index 00000000000..7a9ccebe364
--- /dev/null
+++ b/examples/axum_ssr_router/README.md
@@ -0,0 +1,15 @@
+# SSR Router Example
+
+This example is the same as the function router example, but with
+server-side rendering and hydration support. It reuses the same codebase
+of the function router example.
+
+# How to run this example
+
+1. Build Hydration Bundle
+
+`trunk build`
+
+2. Run the server
+
+`cargo run --features=ssr --bin ssr_router_server -- --dir dist`
diff --git a/examples/axum_ssr_router/index.html b/examples/axum_ssr_router/index.html
new file mode 100644
index 00000000000..98dfce4afeb
--- /dev/null
+++ b/examples/axum_ssr_router/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ Yew SSR Router
+
+
+
+
+
+
diff --git a/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs
new file mode 100644
index 00000000000..571339c8071
--- /dev/null
+++ b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs
@@ -0,0 +1,18 @@
+use axum_ssr_router::{App, AppProps, LINK_ENDPOINT};
+
+fn main() {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let fmt_layer = tracing_subscriber::fmt::layer()
+ .with_ansi(false) // Only partially supported across browsers
+ .without_time() // std::time is not available in browsers
+ .with_writer(tracing_web::MakeWebConsoleWriter::new())
+ .with_filter(tracing_subscriber::filter::LevelFilter::TRACE);
+ use tracing_subscriber::prelude::*;
+ tracing_subscriber::registry().with(fmt_layer).init();
+ }
+ yew::Renderer::::with_props(AppProps {
+ endpoint: LINK_ENDPOINT.into(),
+ })
+ .hydrate();
+}
diff --git a/examples/axum_ssr_router/src/bin/ssr_router_server.rs b/examples/axum_ssr_router/src/bin/ssr_router_server.rs
new file mode 100644
index 00000000000..26e3c4e09ac
--- /dev/null
+++ b/examples/axum_ssr_router/src/bin/ssr_router_server.rs
@@ -0,0 +1,196 @@
+use std::collections::HashMap;
+use std::convert::Infallible;
+use std::future::Future;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+
+use axum::body::Body;
+use axum::extract::{Query, Request, State};
+use axum::handler::HandlerWithoutStateExt;
+use axum::http::Uri;
+use axum::response::IntoResponse;
+use axum::routing::{get, post};
+use axum::Router;
+use axum_ssr_router::{
+ LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT,
+};
+use clap::Parser;
+use function_router::{route_meta, Route};
+use futures::stream::{self, StreamExt};
+use hyper::body::Incoming;
+use hyper_util::rt::TokioIo;
+use hyper_util::server;
+use tokio::net::TcpListener;
+use tower::Service;
+use tower_http::cors::CorsLayer;
+use tower_http::services::ServeDir;
+use yew::platform::Runtime;
+use yew_link::axum::linked_state_handler;
+use yew_link::{Resolver, ResolverProp};
+use yew_router::prelude::Routable;
+
+// We use jemalloc as it produces better performance.
+#[cfg(unix)]
+#[global_allocator]
+static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
+
+/// A basic example
+#[derive(Parser, Debug)]
+struct Opt {
+ /// the "dist" created by trunk directory to be served for hydration.
+ #[clap(short, long)]
+ dir: PathBuf,
+}
+
+fn head_tags_for(path: &str) -> String {
+ let route = Route::recognize(path).unwrap_or(Route::NotFound);
+ let (title, description) = route_meta(&route);
+ format!(
+ "{title} | Yew SSR Router "
+ )
+}
+
+#[derive(Clone)]
+struct AppState {
+ index_html_before: String,
+ index_html_after: String,
+ resolver: ResolverProp,
+}
+
+async fn render(
+ url: Uri,
+ Query(queries): Query>,
+ State(state): State,
+) -> impl IntoResponse {
+ let path = url.path().to_owned();
+
+ // Inject route-specific tags before , outside of Yew rendering.
+ let before = state
+ .index_html_before
+ .replace("", &format!("{}", head_tags_for(&path)));
+ let resolver = state.resolver.clone();
+
+ let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps {
+ url: path.into(),
+ queries,
+ resolver,
+ });
+
+ Body::from_stream(
+ stream::once(async move { before })
+ .chain(renderer.render_stream())
+ .chain(stream::once(async move { state.index_html_after }))
+ .map(Result::<_, Infallible>::Ok),
+ )
+}
+
+// An executor to process requests on the Yew runtime.
+//
+// By spawning requests on the Yew runtime,
+// it processes request on the same thread as the rendering task.
+//
+// This increases performance in some environments (e.g.: in VM).
+#[derive(Clone, Default)]
+struct Executor {
+ inner: Runtime,
+}
+
+impl hyper::rt::Executor for Executor
+where
+ F: Future + Send + 'static,
+{
+ fn execute(&self, fut: F) {
+ self.inner.spawn_pinned(move || async move {
+ fut.await;
+ });
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let exec = Executor::default();
+ env_logger::init();
+ let opts = Opt::parse();
+
+ let resolver_prop: ResolverProp = Resolver::new()
+ .register_linked::(())
+ .register_linked::(())
+ .register_linked::(())
+ .into();
+ let arc_resolver = resolver_prop.0.clone();
+
+ let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
+ .await
+ .expect("failed to read index.html");
+
+ let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap();
+ let mut index_html_before = index_html_before.to_owned();
+ index_html_before.push_str("");
+ let index_html_after = index_html_after.to_owned();
+
+ let app_state = AppState {
+ index_html_before,
+ index_html_after,
+ resolver: resolver_prop,
+ };
+
+ let app = Router::new()
+ .route(
+ LINK_ENDPOINT,
+ post(linked_state_handler).with_state(arc_resolver),
+ )
+ .fallback_service(
+ ServeDir::new(opts.dir)
+ .append_index_html_on_directories(false)
+ .fallback(get(render).with_state(app_state).into_service()),
+ )
+ .layer(CorsLayer::permissive());
+
+ let addr: SocketAddr = ([0, 0, 0, 0], 8080).into();
+ println!("You can view the website at: http://localhost:8080/");
+
+ let listener = TcpListener::bind(addr).await?;
+
+ // Continuously accept new connections.
+ loop {
+ // In this example we discard the remote address. See `fn serve_with_connect_info` for how
+ // to expose that.
+ let (socket, _remote_addr) = listener.accept().await.unwrap();
+
+ // We don't need to call `poll_ready` because `Router` is always ready.
+ let tower_service = app.clone();
+
+ let exec = exec.clone();
+ // Spawn a task to handle the connection. That way we can handle multiple connections
+ // concurrently.
+ tokio::spawn(async move {
+ // Hyper has its own `AsyncRead` and `AsyncWrite` traits and doesn't use tokio.
+ // `TokioIo` converts between them.
+ let socket = TokioIo::new(socket);
+
+ // Hyper also has its own `Service` trait and doesn't use tower. We can use
+ // `hyper::service::service_fn` to create a hyper `Service` that calls our app through
+ // `tower::Service::call`.
+ let hyper_service = hyper::service::service_fn(move |request: Request| {
+ // We have to clone `tower_service` because hyper's `Service` uses `&self` whereas
+ // tower's `Service` requires `&mut self`.
+ //
+ // We don't need to call `poll_ready` since `Router` is always ready.
+ tower_service.clone().call(request)
+ });
+
+ // `server::conn::auto::Builder` supports both http1 and http2.
+ //
+ // `TokioExecutor` tells hyper to use `tokio::spawn` to spawn tasks.
+ if let Err(err) = server::conn::auto::Builder::new(exec)
+ // `serve_connection_with_upgrades` is required for websockets. If you don't need
+ // that you can use `serve_connection` instead.
+ .serve_connection_with_upgrades(socket, hyper_service)
+ .await
+ {
+ eprintln!("failed to serve connection: {err:#}");
+ }
+ });
+ }
+}
diff --git a/examples/axum_ssr_router/src/lib.rs b/examples/axum_ssr_router/src/lib.rs
new file mode 100644
index 00000000000..3c20d42c984
--- /dev/null
+++ b/examples/axum_ssr_router/src/lib.rs
@@ -0,0 +1,468 @@
+use std::collections::HashMap;
+
+use function_router::content;
+#[cfg(not(target_arch = "wasm32"))]
+use function_router::Generated;
+use serde::{Deserialize, Serialize};
+use yew::prelude::*;
+use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp};
+use yew_router::prelude::*;
+
+pub const LINK_ENDPOINT: &str = "/api/link";
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LinkedPost(pub content::Post);
+
+#[linked_state]
+impl LinkedState for LinkedPost {
+ type Context = ();
+ type Input = u32;
+
+ async fn resolve(_ctx: &(), seed: &u32) -> Self {
+ Self(content::Post::generate_from_seed(*seed))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LinkedAuthor(pub content::Author);
+
+#[linked_state]
+impl LinkedState for LinkedAuthor {
+ type Context = ();
+ type Input = u32;
+
+ async fn resolve(_ctx: &(), seed: &u32) -> Self {
+ Self(content::Author::generate_from_seed(*seed))
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LinkedPostMeta(pub content::PostMeta);
+
+#[linked_state]
+impl LinkedState for LinkedPostMeta {
+ type Context = ();
+ type Input = u32;
+
+ async fn resolve(_ctx: &(), seed: &u32) -> Self {
+ Self(content::PostMeta::generate_from_seed(*seed))
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct PostProps {
+ pub id: u32,
+}
+
+#[component]
+pub fn PostPage(props: &PostProps) -> HtmlResult {
+ let post = use_linked_state::(props.id)?.data();
+ Ok(render_post(&post.0))
+}
+
+fn render_post(post: &content::Post) -> Html {
+ use content::PostPart;
+
+ let render_quote = |quote: &content::Quote| {
+ html! {
+
+
+
+
+
+
+
+
+
classes={classes!("is-size-5")} to={function_router::Route::Author { id: quote.author.seed }}>
+
{ "e.author.name }
+ >
+
{ "e.content }
+
+
+
+ }
+ };
+
+ let render_section_hero = |section: &content::Section| {
+ html! {
+
+
+
+
+
{ §ion.title }
+
+
+
+ }
+ };
+
+ let render_section = |section: &content::Section, show_hero: bool| {
+ let hero = if show_hero {
+ render_section_hero(section)
+ } else {
+ html! {}
+ };
+ html! {
+
+ { hero }
+
+ for p in section.paragraphs.iter() {
+
{ p }
+ }
+
+
+ }
+ };
+
+ let view_content = {
+ let mut show_hero = false;
+ let parts: Vec = post
+ .content
+ .iter()
+ .map(|part| match part {
+ PostPart::Section(section) => {
+ let html = render_section(section, show_hero);
+ show_hero = true;
+ html
+ }
+ PostPart::Quote(quote) => {
+ show_hero = false;
+ render_quote(quote)
+ }
+ })
+ .collect();
+ html! {
+ {for parts}
+ }
+ };
+
+ html! {
+ <>
+
+
+
+
+
{ &post.meta.title }
+
+ { "by " }
+ classes={classes!("has-text-weight-semibold")} to={function_router::Route::Author { id: post.meta.author.seed }}>
+ { &post.meta.author.name }
+ >
+
+
+ for kw in &post.meta.keywords {
+ { kw }
+ }
+
+
+
+
+ { view_content }
+ >
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct AuthorProps {
+ pub id: u32,
+}
+
+#[component]
+pub fn AuthorPage(props: &AuthorProps) -> HtmlResult {
+ let author = use_linked_state::(props.id)?.data();
+ Ok(render_author(&author.0))
+}
+
+fn render_author(author: &content::Author) -> Html {
+ html! {
+
+
+
+
+
+
+ { "Interests" }
+
+ for tag in &author.keywords {
+ { tag }
+ }
+
+
+
+
+
+
+
+
+
+
+
+
{ "About me" }
+
+ { "This author has chosen not to reveal anything about themselves" }
+
+
+
+
+
+
+
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+struct CardProps {
+ seed: u32,
+}
+
+#[component]
+fn LinkedPostCard(props: &CardProps) -> HtmlResult {
+ let meta = use_linked_state::(props.seed)?.data();
+ let meta = &meta.0;
+ Ok(html! {
+
+
+
+
+
+
+
+ classes={classes!("title", "is-block")} to={function_router::Route::Post { id: meta.seed }}>
+ { &meta.title }
+ >
+ classes={classes!("subtitle", "is-block")} to={function_router::Route::Author { id: meta.author.seed }}>
+ { &meta.author.name }
+ >
+
+
+ })
+}
+
+#[component]
+fn LinkedAuthorCard(props: &CardProps) -> HtmlResult {
+ let author = use_linked_state::(props.seed)?.data();
+ let author = &author.0;
+ Ok(html! {
+
+ })
+}
+
+const ITEMS_PER_PAGE: u32 = 10;
+const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE;
+
+#[component]
+fn LinkedPostList() -> Html {
+ use function_router::components::pagination::{PageQuery, Pagination};
+
+ let location = use_location().unwrap();
+ let current_page = location.query::().map(|it| it.page).unwrap_or(1);
+
+ let start_seed = (current_page - 1) * ITEMS_PER_PAGE;
+ let half = ITEMS_PER_PAGE / 2;
+
+ html! {
+
+
{ "Posts" }
+
{ "All of our quality writing in one place" }
+
+
+
+ for offset in 0..half {
+
+ {"Loading..."}
}}>
+
+
+
+ }
+
+
+
+
+ for offset in half..ITEMS_PER_PAGE {
+
+ {"Loading..."}
}}>
+
+
+
+ }
+
+
+
+
+
+ }
+}
+
+#[component]
+fn LinkedAuthorList() -> Html {
+ let seeds = use_state(|| {
+ use rand::{distr, Rng};
+ rand::rng()
+ .sample_iter(distr::StandardUniform)
+ .take(2)
+ .collect::>()
+ });
+
+ let on_complete = {
+ let seeds = seeds.clone();
+ Callback::from(move |_| {
+ use rand::{distr, Rng};
+ seeds.set(
+ rand::rng()
+ .sample_iter(distr::StandardUniform)
+ .take(2)
+ .collect(),
+ );
+ })
+ };
+
+ html! {
+
+
+
+
+
{ "Authors" }
+
+ { "Meet the definitely real people behind your favourite Yew content" }
+
+
+
+
+
+ { "It wouldn't be fair " }
+ { "(or possible :P)" }
+ {" to list each and every author in alphabetical order."}
+
+ { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" }
+
+
+
+ for seed in seeds.iter().copied() {
+
+
+ }
+
+
+
+
+ }
+}
+
+fn switch(routes: function_router::Route) -> Html {
+ use function_router::Route;
+
+ match routes {
+ Route::Post { id } => html! {
+ {"Loading post..."} }}>
+
+
+ },
+ Route::Author { id } => html! {
+ {"Loading author..."} }}>
+
+
+ },
+ Route::Posts => html! { },
+ Route::Authors => html! { },
+ Route::Home => html! { },
+ Route::NotFound => html! { },
+ }
+}
+
+#[derive(Properties, PartialEq)]
+pub struct AppProps {
+ pub endpoint: AttrValue,
+}
+
+#[component]
+pub fn App(props: &AppProps) -> Html {
+ html! {
+
+
+
+
+ render={switch} />
+
+
+
+
+ }
+}
+
+#[derive(Properties, PartialEq, Debug)]
+pub struct ServerAppProps {
+ pub url: AttrValue,
+ pub queries: HashMap,
+ pub resolver: ResolverProp,
+}
+
+#[component]
+pub fn ServerApp(props: &ServerAppProps) -> Html {
+ use yew_router::history::{AnyHistory, History, MemoryHistory};
+
+ let history = AnyHistory::from(MemoryHistory::new());
+ history
+ .push_with_query(&*props.url, &props.queries)
+ .unwrap();
+
+ html! {
+
+
+
+
+ render={switch} />
+
+
+
+
+ }
+}
diff --git a/examples/axum_ssr_router/tests/e2e.rs b/examples/axum_ssr_router/tests/e2e.rs
new file mode 100644
index 00000000000..39887013cf1
--- /dev/null
+++ b/examples/axum_ssr_router/tests/e2e.rs
@@ -0,0 +1,199 @@
+#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
+
+use gloo::utils::document;
+use ssr_e2e_harness::{
+ clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page,
+ wait_for,
+};
+use ssr_router::{App, AppProps, LINK_ENDPOINT};
+use wasm_bindgen::JsCast;
+use wasm_bindgen_test::*;
+use yew::Renderer;
+
+wasm_bindgen_test_configure!(run_in_browser);
+
+const SERVER_BASE: &str = "http://127.0.0.1:8080";
+
+fn endpoint() -> String {
+ format!("{SERVER_BASE}{LINK_ENDPOINT}")
+}
+
+fn make_renderer() -> Renderer {
+ Renderer::::with_root_and_props(
+ output_element(),
+ AppProps {
+ endpoint: endpoint().into(),
+ },
+ )
+}
+
+fn get_title_text() -> Option {
+ document()
+ .query_selector("h1.title")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+}
+
+fn post_body_text() -> String {
+ output_element()
+ .query_selector(".section.container")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+ .unwrap_or_default()
+}
+
+fn extract_text_from_html(html: &str, selector: &str) -> Option {
+ let container = document().create_element("div").unwrap();
+ container.set_inner_html(html);
+ container
+ .query_selector(selector)
+ .ok()
+ .flatten()
+ .and_then(|el| el.text_content())
+}
+
+#[wasm_bindgen_test]
+async fn ssr_hydration_and_client_navigation() {
+ // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link --
+
+ let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await;
+ let ssr_title = extract_text_from_html(&ssr_html, "h1.title")
+ .expect("SSR HTML for /posts/0 should contain h1.title");
+ let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default();
+
+ clear_resource_timings();
+
+ output_element().set_inner_html(&ssr_html);
+ ssr_e2e_harness::push_route("/posts/0");
+ let app = make_renderer().hydrate();
+
+ wait_for(
+ || {
+ let html = output_element().inner_html();
+ html.contains("") && !html.contains("Loading post...")
+ },
+ 5000,
+ "post page content after SSR hydration",
+ )
+ .await;
+
+ let link_fetches = resource_request_count(LINK_ENDPOINT);
+ let title = get_title_text();
+
+ assert_eq!(
+ link_fetches, 0,
+ "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}"
+ );
+ let title = title.expect("h1.title should be present on the SSR post page");
+ assert!(!title.is_empty(), "SSR post title should not be empty");
+
+ // -- Part 2: Navigate to /posts within the same app, then to /posts/0 --
+
+ yew::scheduler::flush().await;
+
+ clear_resource_timings();
+
+ let posts_link: web_sys::HtmlElement = output_element()
+ .query_selector("a.navbar-item[href='/posts']")
+ .unwrap()
+ .expect("Posts navbar link should exist")
+ .dyn_into()
+ .unwrap();
+ posts_link.click();
+ yew::scheduler::flush().await;
+
+ wait_for(
+ || {
+ document()
+ .query_selector("a.title.is-block")
+ .ok()
+ .flatten()
+ .is_some()
+ && get_title_text().as_deref() == Some("Posts")
+ },
+ 15000,
+ "posts list after client-side navigation to /posts",
+ )
+ .await;
+
+ clear_resource_timings();
+
+ wait_for(
+ || {
+ output_element()
+ .query_selector("a.title.is-block[href='/posts/0']")
+ .ok()
+ .flatten()
+ .is_some()
+ },
+ 15000,
+ "post 0 card link on posts list",
+ )
+ .await;
+
+ let post_link: web_sys::HtmlElement = output_element()
+ .query_selector("a.title.is-block[href='/posts/0']")
+ .unwrap()
+ .unwrap()
+ .dyn_into()
+ .unwrap();
+ post_link.click();
+ yew::scheduler::flush().await;
+
+ wait_for(
+ || {
+ document()
+ .query_selector("h2.subtitle")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+ .is_some_and(|text| text.starts_with("by "))
+ },
+ 15000,
+ "post page content after client-side navigation to /posts/0",
+ )
+ .await;
+
+ // -- Part 3: Verify fetch happened and content matches SSR --
+
+ let nav_link_fetches = resource_request_count(LINK_ENDPOINT);
+ let nav_title = get_title_text();
+ let nav_body = post_body_text();
+
+ assert!(
+ nav_link_fetches >= 1,
+ "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \
+ got {nav_link_fetches}"
+ );
+
+ let nav_title = nav_title.expect("h1.title should be present after client-side navigation");
+ assert_eq!(
+ ssr_title, nav_title,
+ "post title should match between SSR and client-side navigation"
+ );
+ assert_eq!(
+ ssr_body, nav_body,
+ "post body should match between SSR and client-side navigation"
+ );
+
+ app.destroy();
+ yew::scheduler::flush().await;
+}
+
+#[wasm_bindgen_test]
+async fn hydrate_home() {
+ setup_ssr_page(SERVER_BASE, "/").await;
+ let app = make_renderer().hydrate();
+
+ wait_for(
+ || output_element().inner_html().contains("Welcome"),
+ 5000,
+ "home page content after hydration",
+ )
+ .await;
+
+ app.destroy();
+ yew::scheduler::flush().await;
+}
From 686fc3d20c498e04037bc7dc5185ef60e7b372af Mon Sep 17 00:00:00 2001
From: stifskere
Date: Mon, 6 Apr 2026 19:57:33 +0200
Subject: [PATCH 07/16] docs: #4113 add the example changes to
examples/readme.md
---
examples/README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/examples/README.md b/examples/README.md
index 01c7d15d333..8053fc64743 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -58,7 +58,8 @@ As an example, check out the TodoMVC example here: ` support. |
| [timer](timer) | [S] | Demonstrates the use of the interval and timeout services. |
| [timer_functional](timer_functional) | [F] | Demonstrates the use of the interval and timeout services using function components |
From bccf5d07de7cdccd5f8878984c22862479133229 Mon Sep 17 00:00:00 2001
From: Matt Yan
Date: Tue, 7 Apr 2026 17:26:47 +0900
Subject: [PATCH 08/16] fix: bump actix_ssr_router to edition 2024 and fmt
---
examples/actix_ssr_router/Cargo.toml | 3 ++-
examples/actix_ssr_router/src/bin/ssr_router_server.rs | 6 +++---
examples/actix_ssr_router/src/lib.rs | 4 ++--
3 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml
index 7b4759f4640..20f3e4f6582 100644
--- a/examples/actix_ssr_router/Cargo.toml
+++ b/examples/actix_ssr_router/Cargo.toml
@@ -1,7 +1,8 @@
[package]
name = "actix_ssr_router"
version = "0.1.0"
-edition = "2021"
+edition = "2024"
+rust-version.workspace = true
[[bin]]
name = "ssr_router_hydrate"
diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
index e18f7db05a2..2c704eb2ce3 100644
--- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs
+++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
@@ -3,14 +3,14 @@ use std::io::Result as IoResult;
use std::path::PathBuf;
use actix_ssr_router::{
- LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT,
+ LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps,
};
use actix_web::http::Uri;
-use actix_web::web::{get, post, Data, Query};
+use actix_web::web::{Data, Query, get, post};
use actix_web::{App, Error, HttpResponse, HttpServer};
use bytes::Bytes;
use clap::Parser;
-use function_router::{route_meta, Route};
+use function_router::{Route, route_meta};
use futures::stream::{self, StreamExt};
use yew_link::actix_web::linked_state_handler;
use yew_link::{Resolver, ResolverProp};
diff --git a/examples/actix_ssr_router/src/lib.rs b/examples/actix_ssr_router/src/lib.rs
index 8d786480e46..c0103893e9c 100644
--- a/examples/actix_ssr_router/src/lib.rs
+++ b/examples/actix_ssr_router/src/lib.rs
@@ -1,12 +1,12 @@
use std::collections::HashMap;
-use function_router::content;
#[cfg(not(target_arch = "wasm32"))]
use function_router::Generated;
+use function_router::content;
use rand::RngExt;
use serde::{Deserialize, Serialize};
use yew::prelude::*;
-use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp};
+use yew_link::{LinkProvider, ResolverProp, linked_state, use_linked_state};
use yew_router::prelude::*;
pub const LINK_ENDPOINT: &str = "/api/link";
From 26323c548d901ce25b128ac890a9eff446607516 Mon Sep 17 00:00:00 2001
From: Matt Yan
Date: Tue, 7 Apr 2026 17:34:21 +0900
Subject: [PATCH 09/16] ci: update SSR E2E matrix for renamed axum_ssr_router
and new actix_ssr_router
---
.github/workflows/main-checks.yml | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml
index 89f6a20a491..a6022fdda3d 100644
--- a/.github/workflows/main-checks.yml
+++ b/.github/workflows/main-checks.yml
@@ -301,9 +301,12 @@ jobs:
fail-fast: false
matrix:
include:
- - example: ssr_router
+ - example: axum_ssr_router
server_bin: ssr_router_server
- trunk_dir: examples/ssr_router
+ trunk_dir: examples/axum_ssr_router
+ - example: actix_ssr_router
+ server_bin: ssr_router_server
+ trunk_dir: examples/actix_ssr_router
- example: simple_ssr
server_bin: simple_ssr_server
trunk_dir: examples/simple_ssr
From 423a77ef0aa465f11c7ff4d54fab53b970a09921 Mon Sep 17 00:00:00 2001
From: Matt Yan
Date: Tue, 7 Apr 2026 17:45:01 +0900
Subject: [PATCH 10/16] fix: update e2e tests to import from renamed crates
---
examples/actix_ssr_router/tests/e2e.rs | 2 +-
examples/axum_ssr_router/tests/e2e.rs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/examples/actix_ssr_router/tests/e2e.rs b/examples/actix_ssr_router/tests/e2e.rs
index 39887013cf1..0b5b18b38e0 100644
--- a/examples/actix_ssr_router/tests/e2e.rs
+++ b/examples/actix_ssr_router/tests/e2e.rs
@@ -1,11 +1,11 @@
#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
+use actix_ssr_router::{App, AppProps, LINK_ENDPOINT};
use gloo::utils::document;
use ssr_e2e_harness::{
clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page,
wait_for,
};
-use ssr_router::{App, AppProps, LINK_ENDPOINT};
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
use yew::Renderer;
diff --git a/examples/axum_ssr_router/tests/e2e.rs b/examples/axum_ssr_router/tests/e2e.rs
index 39887013cf1..9bf3e10a8c3 100644
--- a/examples/axum_ssr_router/tests/e2e.rs
+++ b/examples/axum_ssr_router/tests/e2e.rs
@@ -1,11 +1,11 @@
#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
+use axum_ssr_router::{App, AppProps, LINK_ENDPOINT};
use gloo::utils::document;
use ssr_e2e_harness::{
clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page,
wait_for,
};
-use ssr_router::{App, AppProps, LINK_ENDPOINT};
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
use yew::Renderer;
From 5990ef81d170d0cf51976027f97588d7cb15a3cd Mon Sep 17 00:00:00 2001
From: Matt Yan
Date: Tue, 7 Apr 2026 18:26:40 +0900
Subject: [PATCH 11/16] fix: actix_ssr_router needs CORS and static file
serving for e2e
---
Cargo.lock | 52 +++++++++++++++++++
examples/actix_ssr_router/Cargo.toml | 2 +
.../src/bin/ssr_router_server.rs | 10 +++-
3 files changed, 63 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
index 4ee7144db89..167b5ff36e1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -19,6 +19,44 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "actix-cors"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d"
+dependencies = [
+ "actix-utils",
+ "actix-web",
+ "derive_more",
+ "futures-util",
+ "log",
+ "once_cell",
+ "smallvec",
+]
+
+[[package]]
+name = "actix-files"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b"
+dependencies = [
+ "actix-http",
+ "actix-service",
+ "actix-utils",
+ "actix-web",
+ "bitflags",
+ "bytes",
+ "derive_more",
+ "futures-core",
+ "http-range",
+ "log",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "v_htmlescape",
+]
+
[[package]]
name = "actix-http"
version = "3.12.0"
@@ -189,6 +227,8 @@ dependencies = [
name = "actix_ssr_router"
version = "0.1.0"
dependencies = [
+ "actix-cors",
+ "actix-files",
"actix-web",
"bytes",
"clap",
@@ -1938,6 +1978,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
[[package]]
name = "http-range-header"
version = "0.4.2"
@@ -4400,6 +4446,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "v_htmlescape"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
+
[[package]]
name = "valuable"
version = "0.1.1"
diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml
index 20f3e4f6582..41ec5167978 100644
--- a/examples/actix_ssr_router/Cargo.toml
+++ b/examples/actix_ssr_router/Cargo.toml
@@ -34,6 +34,8 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"
env_logger = "0.11"
clap = { workspace = true }
actix-web = "4.13.0"
+actix-cors = "0.7"
+actix-files = "0.6"
[target.'cfg(unix)'.dependencies]
jemallocator = "0.5"
diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
index 2c704eb2ce3..5dc9a4959a5 100644
--- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs
+++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
@@ -2,6 +2,8 @@ use std::collections::HashMap;
use std::io::Result as IoResult;
use std::path::PathBuf;
+use actix_cors::Cors;
+use actix_files::Files;
use actix_ssr_router::{
LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps,
};
@@ -100,11 +102,17 @@ async fn main() -> IoResult<()> {
.into(),
});
+ let dir = opts.dir.clone();
HttpServer::new(move || {
App::new()
+ .wrap(Cors::permissive())
.app_data(app_state.clone())
.route(LINK_ENDPOINT, post().to(linked_state_handler))
- .default_service(get().to(render))
+ .service(
+ Files::new("/", &dir)
+ .index_file("__no_index__")
+ .default_handler(get().to(render)),
+ )
})
.bind(("0.0.0.0", 8080))?
.run()
From 582df96659b2b1770bae3e497c43cf68545f43fa Mon Sep 17 00:00:00 2001
From: Matt Yan
Date: Tue, 7 Apr 2026 19:02:28 +0900
Subject: [PATCH 12/16] fix: actix_ssr_router resolver registration and HTML
content type
The linked_state_handler in yew-link extracts Data, but the
server only registered Data, returning 500. Install the inner
Arc as Data via app_data alongside the AppState.
Also set content_type=text/html on the SSR streaming response so browsers
render it instead of downloading it.
---
.../src/bin/ssr_router_server.rs | 32 +++++++++++--------
1 file changed, 19 insertions(+), 13 deletions(-)
diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
index 5dc9a4959a5..605c68ae728 100644
--- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs
+++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
@@ -68,14 +68,16 @@ async fn render(
resolver,
});
- HttpResponse::Ok().streaming(
- stream::once(async move { Bytes::from(before) })
- .chain(renderer.render_stream().map(Bytes::from))
- .chain(stream::once(async move {
- Bytes::from(state.index_html_after.clone())
- }))
- .map(Ok::),
- )
+ HttpResponse::Ok()
+ .content_type("text/html; charset=utf-8")
+ .streaming(
+ stream::once(async move { Bytes::from(before) })
+ .chain(renderer.render_stream().map(Bytes::from))
+ .chain(stream::once(async move {
+ Bytes::from(state.index_html_after.clone())
+ }))
+ .map(Ok::),
+ )
}
#[actix_web::main]
@@ -92,14 +94,17 @@ async fn main() -> IoResult<()> {
index_html_before.push_str("");
let index_html_after = index_html_after.to_owned();
+ let resolver_prop: ResolverProp = Resolver::new()
+ .register_linked::(())
+ .register_linked::(())
+ .register_linked::(())
+ .into();
+ let resolver_data = Data::from(resolver_prop.0.clone());
+
let app_state = Data::new(AppState {
index_html_before,
index_html_after,
- resolver: Resolver::new()
- .register_linked::(())
- .register_linked::(())
- .register_linked::(())
- .into(),
+ resolver: resolver_prop,
});
let dir = opts.dir.clone();
@@ -107,6 +112,7 @@ async fn main() -> IoResult<()> {
App::new()
.wrap(Cors::permissive())
.app_data(app_state.clone())
+ .app_data(resolver_data.clone())
.route(LINK_ENDPOINT, post().to(linked_state_handler))
.service(
Files::new("/", &dir)
From 30ec78ead00b2a890f82e81394be01573ccc05b6 Mon Sep 17 00:00:00 2001
From: Matt Yan
Date: Tue, 7 Apr 2026 21:39:04 +0900
Subject: [PATCH 13/16] fix: feature soundness, CI, website i18n
---
.github/workflows/clippy.yml | 4 +-
.github/workflows/main-checks.yml | 2 +-
Cargo.lock | 19 ++-
Cargo.toml | 2 +
examples/actix_ssr_router/Cargo.toml | 2 +-
.../src/bin/ssr_router_server.rs | 2 +-
packages/yew-link/Cargo.toml | 7 +-
packages/yew-link/src/lib.rs | 9 +-
packages/yew-link/src/services.rs | 2 +-
tools/website-test/Cargo.toml | 3 +
.../advanced-topics/server-side-rendering.mdx | 67 ++++++--
.../advanced-topics/server-side-rendering.mdx | 154 +++++++++++++++++-
.../advanced-topics/server-side-rendering.mdx | 154 +++++++++++++++++-
.../advanced-topics/server-side-rendering.mdx | 154 +++++++++++++++++-
14 files changed, 539 insertions(+), 42 deletions(-)
diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml
index 62ef8c6fa46..7dd10a983df 100644
--- a/.github/workflows/clippy.yml
+++ b/.github/workflows/clippy.yml
@@ -44,7 +44,7 @@ jobs:
if: matrix.profile == 'dev'
run: >-
cargo hack clippy
- -p yew -p yew-agent -p yew-router
+ -p yew -p yew-agent -p yew-router -p yew-link
--feature-powerset --no-dev-deps
--keep-going
-- -D warnings
@@ -53,7 +53,7 @@ jobs:
if: matrix.profile == 'release'
run: >-
cargo hack clippy
- -p yew -p yew-agent -p yew-router
+ -p yew -p yew-agent -p yew-router -p yew-link
--feature-powerset --no-dev-deps
--keep-going --release
-- -D warnings
diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml
index a6022fdda3d..6f2f9fb8dc9 100644
--- a/.github/workflows/main-checks.yml
+++ b/.github/workflows/main-checks.yml
@@ -144,7 +144,7 @@ jobs:
RUSTFLAGS: ${{ matrix.toolchain == 'nightly' && '--cfg nightly_yew' || '' }}
run: |
if [[ "${{ matrix.toolchain }}" == "1.85.0" ]]; then
- cargo test --all-targets -p yew-agent -p yew-agent-macro -p yew-router
+ cargo test --all-targets -p yew-agent -p yew-agent-macro -p yew-router -p yew-link -p yew-link-macro
else
ls packages | grep -v "^yew$" | xargs -I {} cargo test --all-targets -p {}
fi
diff --git a/Cargo.lock b/Cargo.lock
index 167b5ff36e1..a4793033d7b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -59,9 +59,9 @@ dependencies = [
[[package]]
name = "actix-http"
-version = "3.12.0"
+version = "3.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e"
+checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
dependencies = [
"actix-codec",
"actix-rt",
@@ -170,9 +170,9 @@ dependencies = [
[[package]]
name = "actix-web"
-version = "4.13.0"
+version = "4.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf"
+checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6"
dependencies = [
"actix-codec",
"actix-http",
@@ -2552,9 +2552,9 @@ dependencies = [
[[package]]
name = "libz-sys"
-version = "1.1.27"
+version = "1.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3884c0d1cee1f72141b54543005490614e7f96890d10c99a238a54cbd0e43d81"
+checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22"
dependencies = [
"cc",
"libc",
@@ -4365,9 +4365,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
-version = "1.12.0"
+version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
@@ -4765,12 +4765,14 @@ dependencies = [
name = "website-test"
version = "0.1.0"
dependencies = [
+ "actix-web",
"derive_more",
"glob",
"gloo",
"gloo-net 0.7.0",
"js-sys",
"serde",
+ "serde_json",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -4779,6 +4781,7 @@ dependencies = [
"yew",
"yew-agent",
"yew-autoprops",
+ "yew-link",
"yew-router",
]
diff --git a/Cargo.toml b/Cargo.toml
index 18c98b23072..05b3cf7a5c1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -66,3 +66,5 @@ thiserror = "2.0"
bincode = { version = "2.0.0-rc.3", features = ["serde"] }
reqwest = "0.13"
axum = "0.8"
+# 4.13 requires rustc > 1.85
+actix-web = "<4.13"
diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml
index 41ec5167978..6cf2e181194 100644
--- a/examples/actix_ssr_router/Cargo.toml
+++ b/examples/actix_ssr_router/Cargo.toml
@@ -33,7 +33,7 @@ tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] }
env_logger = "0.11"
clap = { workspace = true }
-actix-web = "4.13.0"
+actix-web = { workspace = true }
actix-cors = "0.7"
actix-files = "0.6"
diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
index 605c68ae728..43569b7831f 100644
--- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs
+++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
@@ -14,7 +14,7 @@ use bytes::Bytes;
use clap::Parser;
use function_router::{Route, route_meta};
use futures::stream::{self, StreamExt};
-use yew_link::actix_web::linked_state_handler;
+use yew_link::actix::linked_state_handler;
use yew_link::{Resolver, ResolverProp};
use yew_router::prelude::Routable;
diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml
index 6710b93c495..6b801a6c854 100644
--- a/packages/yew-link/Cargo.toml
+++ b/packages/yew-link/Cargo.toml
@@ -20,15 +20,14 @@ wasm-bindgen-futures = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
axum = { workspace = true, optional = true }
-actix-web = { version = "4.13", optional = true }
+actix-web = { workspace = true, optional = true }
[features]
default = []
ssr = ["yew/ssr"]
hydration = ["yew/hydration"]
-axum = ["dep:axum", "services"]
-actix = ["dep:actix-web", "services"]
-services = []
+axum = ["dep:axum"]
+actix = ["dep:actix-web"]
[lints]
workspace = true
diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs
index d31901b11fa..f889f5291c9 100644
--- a/packages/yew-link/src/lib.rs
+++ b/packages/yew-link/src/lib.rs
@@ -511,6 +511,7 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult().expect("use_linked_state requires a LinkProvider");
+ #[cfg(any(feature = "ssr", target_arch = "wasm32"))]
type Prepared = Result>;
#[cfg(feature = "ssr")]
@@ -540,9 +541,7 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult, T::Input>(
- input,
- )?;
+ let _ = input;
Ok(LinkedStateHandle {
result: Err(LinkError::Internal(
"yew-link requires the `ssr` feature (server) or a wasm32 target (client)".into(),
@@ -678,8 +677,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult(db_pool.clone())
+ Resolver::new()
+ .register_linked::(db_pool.clone())
);
HttpServer::new(move || {
- App::new()
- .route("/api/link", post().to(linked_state_handler))
- .app_data(resolver)
+ App::new()
+ .app_data(resolver.clone())
+ .route("/api/link", post().to(linked_state_handler))
})
- .bind(("0.0.0.0", 8080))?
- .run()
- .await
+.bind(("0.0.0.0", 8080))?
+.run()
+.await
+```
+
+
+Why `actix-web` is pinned to 4.12, and how to implement a handler for other server frameworks
+
+The `actix` feature is pinned to `actix-web 4.12.x` because Yew's MSRV is
+`1.85`, while `actix-web 4.13` and newer require `rustc 1.88`. This pin lives
+inside `yew-link` only so the rest of the workspace stays buildable on the
+supported toolchain; it does not constrain what version of `actix-web` you
+use elsewhere in your own application.
+
+If you need a newer `actix-web` (or any other web framework) and do not want
+to enable the bundled feature, the handler is small enough to inline
+yourself. The only public surface you need is `Resolver::resolve_request`
+and the wire-format type `LinkRequest`:
+
+```rust
+use actix_web::HttpResponse;
+use actix_web::web::{Data, Json};
+use serde_json::json;
+use yew_link::{LinkRequest, Resolver};
+
+pub async fn linked_state_handler(
+ resolver: Data,
+ Json(req): Json,
+) -> HttpResponse {
+ match resolver.resolve_request(&req).await {
+ Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })),
+ Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })),
+ }
+}
```
+The same shape works for `axum`, `warp`, `rocket`, or any framework that can
+deserialize JSON into `LinkRequest`, call an async function, and serialize a
+JSON response. Construct the response wire format with `serde_json::json!`
+to keep yew-link's internal `LinkResponse` type out of your dependency
+surface.
+
+
+
#### Component usage
```rust ,ignore
@@ -247,7 +287,7 @@ fn PostPage(props: &PostPageProps) -> HtmlResult {
During SSR the state is resolved locally via the `Resolver` and embedded in the HTML through `use_prepared_state`. On hydration the client reads the embedded state with zero network requests. On subsequent client-side navigations the hook fetches from the `LinkProvider`'s endpoint URL automatically.
-See the [`ssr_router` example](https://github.com/yewstack/yew/tree/master/examples/ssr_router) for a full working demo.
+See the [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) and [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) examples for full working demos.
## Rendering `` Tags
@@ -259,7 +299,7 @@ load.
it has no access to ``. Head tags must therefore be generated **on the server, outside of
Yew**, and spliced into the HTML template before it is sent to the client.
-The [`ssr_router` example](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) demonstrates this pattern: the server recognizes the
+The [`axum_ssr_router` example](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) demonstrates this pattern: the server recognizes the
route from the request URL, generates the appropriate `` and ` `
tags, and injects them into the Trunk-generated `index.html` before
``.
@@ -335,7 +375,8 @@ fn main() {
```
Example: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr)
-Example: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router)
+Example: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router)
+Example: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router)
## Single thread mode
diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx
index 6496ca5962c..3df36e9cfde 100644
--- a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx
+++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx
@@ -84,13 +84,162 @@ Yewは、` ` を使用してこの問題を解決する異なるア
この方法により、開発者はサーバーサイドレンダリングに対応したクライアント非依存のアプリケーションを簡単に構築し、データ取得を行うことができます。
+### 低レベルフック
+
+Yew は、サーバー側で計算した状態をクライアントへ運ぶための低レベルフックを 2 つ提供しています:
+
+- **`use_prepared_state!`** は SSR 中に(必要であれば async な)クロージャを実行し、結果をシリアライズして、ハイドレーション中にクライアントへ届けます。コンポーネントが最初のレンダリング時に必要とするデータの取得に向いています。
+- **`use_transitive_state!`** も同様ですが、クロージャはコンポーネントの SSR 出力が生成された _後_ に実行されます。キャッシュや集約状態の収集に向いています。
+
+どちらも内部で `bincode` と `base64` を使い、HTML に `