diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml
index f25d21553c7..f92fb776be2 100644
--- a/.github/workflows/clippy.yml
+++ b/.github/workflows/clippy.yml
@@ -50,7 +50,7 @@ jobs:
TARGET_FLAGS: ${{ case(matrix.target == 'wasm32', '--target wasm32-unknown-unknown', null) }}
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_FLAG
diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml
index 89f6a20a491..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
@@ -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
diff --git a/.github/workflows/test-website.yml b/.github/workflows/test-website.yml
index b3c9c146fe3..6c2593979ab 100644
--- a/.github/workflows/test-website.yml
+++ b/.github/workflows/test-website.yml
@@ -44,3 +44,21 @@ jobs:
- name: Run website code snippet tests
run: cargo test -p website-test --target wasm32-unknown-unknown
+
+ website_tests_native:
+ name: Tests Website Snippets (native)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup toolchain
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ save-if: ${{ github.ref == 'refs/heads/master' }}
+
+ - name: Run website code snippet tests
+ run: cargo test -p website-test
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 26fef1c45bb..a0755cc49ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,258 @@
# 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",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "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.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
+dependencies = [
+ "actix-codec",
+ "actix-rt",
+ "actix-service",
+ "actix-utils",
+ "base64",
+ "bitflags",
+ "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-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.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6"
+dependencies = [
+ "actix-codec",
+ "actix-http",
+ "actix-macros",
+ "actix-router",
+ "actix-rt",
+ "actix-server",
+ "actix-service",
+ "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 = "actix_ssr_router"
+version = "0.1.0"
+dependencies = [
+ "actix-cors",
+ "actix-files",
+ "actix-web",
+ "bytes",
+ "clap",
+ "env_logger",
+ "function_router",
+ "futures 0.3.32",
+ "getrandom 0.4.2",
+ "gloo",
+ "jemallocator",
+ "rand 0.10.0",
+ "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"
@@ -17,6 +269,27 @@ 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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -99,7 +372,7 @@ version = "0.0.1"
dependencies = [
"chrono",
"futures 0.3.32",
- "gloo-net",
+ "gloo-net 0.7.0",
"yew",
]
@@ -167,7 +440,7 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -198,7 +471,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"mime",
@@ -209,6 +482,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.4.2",
+ "gloo",
+ "hyper",
+ "hyper-util",
+ "jemallocator",
+ "rand 0.10.0",
+ "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"
@@ -317,6 +622,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"
@@ -363,6 +689,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"
@@ -586,6 +921,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"
@@ -762,10 +1117,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]]
@@ -983,6 +1340,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1238,7 +1601,7 @@ dependencies = [
"gloo-events",
"gloo-file",
"gloo-history",
- "gloo-net",
+ "gloo-net 0.7.0",
"gloo-render",
"gloo-storage",
"gloo-timers",
@@ -1309,6 +1672,27 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "gloo-net"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-sink",
+ "gloo-utils 0.2.0",
+ "http 1.4.0",
+ "js-sys",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "gloo-net"
version = "0.7.0"
@@ -1319,7 +1703,7 @@ dependencies = [
"futures-core",
"futures-sink",
"gloo-utils 0.3.0",
- "http",
+ "http 1.4.0",
"js-sys",
"pin-project",
"serde",
@@ -1424,6 +1808,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"
@@ -1435,7 +1838,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
- "http",
+ "http 1.4.0",
"indexmap",
"slab",
"tokio",
@@ -1458,7 +1861,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "foldhash",
+ "foldhash 0.1.5",
]
[[package]]
@@ -1466,6 +1869,11 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
[[package]]
name = "headers"
@@ -1476,7 +1884,7 @@ dependencies = [
"base64",
"bytes",
"headers-core",
- "http",
+ "http 1.4.0",
"httpdate",
"mime",
"sha1",
@@ -1488,7 +1896,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -1526,6 +1934,17 @@ dependencies = [
"utf8-width",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
[[package]]
name = "http"
version = "1.4.0"
@@ -1543,7 +1962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.4.0",
]
[[package]]
@@ -1554,11 +1973,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
+ "http 1.4.0",
"http-body",
"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"
@@ -1587,8 +2012,8 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2",
- "http",
+ "h2 0.4.13",
+ "http 1.4.0",
"http-body",
"httparse",
"httpdate",
@@ -1605,7 +2030,7 @@ version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
- "http",
+ "http 1.4.0",
"hyper",
"hyper-util",
"rustls",
@@ -1625,14 +2050,14 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
- "socket2",
+ "socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
@@ -1801,6 +2226,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"
@@ -2061,6 +2492,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"
@@ -2115,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",
@@ -2146,6 +2583,23 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+[[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"
@@ -2161,6 +2615,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "lru"
+version = "0.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2228,6 +2691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
@@ -2367,6 +2831,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"
@@ -2620,7 +3107,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
- "socket2",
+ "socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -2658,7 +3145,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
- "socket2",
+ "socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -2760,6 +3247,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -2807,8 +3303,8 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
- "h2",
- "http",
+ "h2 0.4.13",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -3203,6 +3699,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"
@@ -3239,31 +3745,7 @@ dependencies = [
"gloo",
"wasm-bindgen",
"web-sys",
-]
-
-[[package]]
-name = "ssr_router"
-version = "0.1.0"
-dependencies = [
- "axum",
- "clap",
- "env_logger",
- "function_router",
- "futures 0.3.32",
- "gloo",
- "hyper",
- "hyper-util",
- "jemallocator",
- "ssr-e2e-harness",
- "tokio",
- "tower",
- "tower-http",
- "tracing-subscriber",
- "tracing-web",
- "wasm-bindgen-futures",
- "wasm-bindgen-test",
"yew",
- "yew-router",
]
[[package]]
@@ -3493,10 +3975,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]]
@@ -3505,6 +3989,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"
@@ -3571,9 +4065,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",
]
@@ -3721,7 +4216,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"http-range-header",
@@ -3869,6 +4364,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -3946,6 +4447,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"
@@ -3997,7 +4504,7 @@ dependencies = [
"bytes",
"futures-util",
"headers",
- "http",
+ "http 1.4.0",
"http-body",
"http-body-util",
"hyper",
@@ -4259,12 +4766,15 @@ dependencies = [
name = "website-test"
version = "0.1.0"
dependencies = [
+ "actix-web",
+ "axum",
"derive_more",
"glob",
"gloo",
- "gloo-net",
+ "gloo-net 0.7.0",
"js-sys",
"serde",
+ "serde_json",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -4273,6 +4783,7 @@ dependencies = [
"yew",
"yew-agent",
"yew-autoprops",
+ "yew-link",
"yew-router",
]
@@ -4755,6 +5266,30 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "yew-link"
+version = "0.1.0"
+dependencies = [
+ "actix-web",
+ "axum",
+ "gloo-net 0.6.0",
+ "lru",
+ "serde",
+ "serde_json",
+ "wasm-bindgen-futures",
+ "yew",
+ "yew-link-macro",
+]
+
+[[package]]
+name = "yew-link-macro"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "yew-macro"
version = "0.23.0"
@@ -4934,6 +5469,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 ad7ec04e13c..7364b8c8638 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -66,3 +66,6 @@ chrono = "0.4"
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/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 |
diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml
new file mode 100644
index 00000000000..6cf2e181194
--- /dev/null
+++ b/examples/actix_ssr_router/Cargo.toml
@@ -0,0 +1,56 @@
+[package]
+name = "actix_ssr_router"
+version = "0.1.0"
+edition = "2024"
+rust-version.workspace = true
+
+[[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 = { workspace = true }
+actix-cors = "0.7"
+actix-files = "0.6"
+
+[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/ssr_router/index.html b/examples/actix_ssr_router/index.html
similarity index 100%
rename from examples/ssr_router/index.html
rename to examples/actix_ssr_router/index.html
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..43569b7831f
--- /dev/null
+++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs
@@ -0,0 +1,126 @@
+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,
+};
+use actix_web::http::Uri;
+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, route_meta};
+use futures::stream::{self, StreamExt};
+use yew_link::actix::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()
+ .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]
+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 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_prop,
+ });
+
+ let dir = opts.dir.clone();
+ HttpServer::new(move || {
+ 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)
+ .index_file("__no_index__")
+ .default_handler(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..913510c7dfc
--- /dev/null
+++ b/examples/actix_ssr_router/src/lib.rs
@@ -0,0 +1,467 @@
+use std::collections::HashMap;
+
+#[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::{LinkProvider, ResolverProp, linked_state, use_linked_state};
+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;
+ rand::rng()
+ .sample_iter(distr::StandardUniform)
+ .take(2)
+ .collect::>()
+ });
+
+ let on_complete = {
+ let seeds = seeds.clone();
+ Callback::from(move |_| {
+ use rand::distr;
+ 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..24383f585db
--- /dev/null
+++ b/examples/actix_ssr_router/tests/e2e.rs
@@ -0,0 +1,31 @@
+#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
+
+use actix_ssr_router::{App, AppProps, LINK_ENDPOINT};
+use ssr_e2e_harness::{
+ assert_hydrate_home, assert_ssr_hydration_and_client_navigation, output_element,
+};
+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 make_renderer() -> Renderer {
+ Renderer::::with_root_and_props(
+ output_element(),
+ AppProps {
+ endpoint: format!("{SERVER_BASE}{LINK_ENDPOINT}").into(),
+ },
+ )
+}
+
+#[wasm_bindgen_test]
+async fn ssr_hydration_and_client_navigation() {
+ assert_ssr_hydration_and_client_navigation(make_renderer, SERVER_BASE, LINK_ENDPOINT).await;
+}
+
+#[wasm_bindgen_test]
+async fn hydrate_home() {
+ assert_hydrate_home(make_renderer, SERVER_BASE).await;
+}
diff --git a/examples/ssr_router/Cargo.toml b/examples/axum_ssr_router/Cargo.toml
similarity index 68%
rename from examples/ssr_router/Cargo.toml
rename to examples/axum_ssr_router/Cargo.toml
index baf60b83460..a164375d515 100644
--- a/examples/ssr_router/Cargo.toml
+++ b/examples/axum_ssr_router/Cargo.toml
@@ -1,11 +1,9 @@
[package]
-name = "ssr_router"
+name = "axum_ssr_router"
version = "0.1.0"
edition = "2024"
rust-version.workspace = true
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
[[bin]]
name = "ssr_router_hydrate"
required-features = ["hydration"]
@@ -16,7 +14,13 @@ 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"
@@ -26,9 +30,8 @@ tracing-web.workspace = true
tracing-subscriber.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-yew-router = { path = "../../packages/yew-router" }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] }
-axum = "0.8"
+axum = { workspace = true }
tower = { version = "0.5", features = ["make"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
env_logger = "0.11"
@@ -40,12 +43,15 @@ 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"] }
+yew = { path = "../../packages/yew", features = ["hydration", "test"] }
+yew-link = { path = "../../packages/yew-link", features = ["hydration"] }
[features]
-ssr = ["yew/ssr"]
-hydration = ["yew/hydration"]
+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/axum_ssr_router/README.md
similarity index 100%
rename from examples/ssr_router/README.md
rename to examples/axum_ssr_router/README.md
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/ssr_router/src/bin/ssr_router_hydrate.rs b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs
similarity index 75%
rename from examples/ssr_router/src/bin/ssr_router_hydrate.rs
rename to examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs
index 8b0bc47b80a..571339c8071 100644
--- a/examples/ssr_router/src/bin/ssr_router_hydrate.rs
+++ b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs
@@ -1,4 +1,4 @@
-use function_router::App;
+use axum_ssr_router::{App, AppProps, LINK_ENDPOINT};
fn main() {
#[cfg(target_arch = "wasm32")]
@@ -11,5 +11,8 @@ fn main() {
use tracing_subscriber::prelude::*;
tracing_subscriber::registry().with(fmt_layer).init();
}
- yew::Renderer::::new().hydrate();
+ yew::Renderer::::with_props(AppProps {
+ endpoint: LINK_ENDPOINT.into(),
+ })
+ .hydrate();
}
diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/axum_ssr_router/src/bin/ssr_router_server.rs
similarity index 80%
rename from examples/ssr_router/src/bin/ssr_router_server.rs
rename to examples/axum_ssr_router/src/bin/ssr_router_server.rs
index e042ad6307e..b6d93272f30 100644
--- a/examples/ssr_router/src/bin/ssr_router_server.rs
+++ b/examples/axum_ssr_router/src/bin/ssr_router_server.rs
@@ -9,9 +9,12 @@ use axum::extract::{Query, Request, State};
use axum::handler::HandlerWithoutStateExt;
use axum::http::Uri;
use axum::response::IntoResponse;
-use axum::routing::get;
+use axum::routing::{get, post};
+use axum_ssr_router::{
+ LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps,
+};
use clap::Parser;
-use function_router::{Route, ServerApp, ServerAppProps, route_meta};
+use function_router::{Route, route_meta};
use futures::stream::{self, StreamExt};
use hyper::body::Incoming;
use hyper_util::rt::TokioIo;
@@ -21,6 +24,8 @@ 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.
@@ -45,25 +50,36 @@ fn head_tags_for(path: &str) -> String {
)
}
+#[derive(Clone)]
+struct AppState {
+ index_html_before: String,
+ index_html_after: String,
+ resolver: ResolverProp,
+}
+
async fn render(
url: Uri,
Query(queries): Query>,
- State((index_html_before, index_html_after)): State<(String, String)>,
+ State(state): State,
) -> impl IntoResponse {
let path = url.path().to_owned();
// Inject route-specific tags before , outside of Yew rendering.
- let before = index_html_before.replace("", &format!("{}", head_tags_for(&path)));
+ 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 { index_html_after }))
+ .chain(stream::once(async move { state.index_html_after }))
.map(Result::<_, Infallible>::Ok),
)
}
@@ -93,11 +109,16 @@ where
#[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");
@@ -105,23 +126,27 @@ async fn main() -> Result<(), Box> {
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((index_html_before.clone(), index_html_after.clone()))
- .into_service(),
- ),
+ .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?;
diff --git a/examples/axum_ssr_router/src/lib.rs b/examples/axum_ssr_router/src/lib.rs
new file mode 100644
index 00000000000..913510c7dfc
--- /dev/null
+++ b/examples/axum_ssr_router/src/lib.rs
@@ -0,0 +1,467 @@
+use std::collections::HashMap;
+
+#[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::{LinkProvider, ResolverProp, linked_state, use_linked_state};
+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;
+ rand::rng()
+ .sample_iter(distr::StandardUniform)
+ .take(2)
+ .collect::>()
+ });
+
+ let on_complete = {
+ let seeds = seeds.clone();
+ Callback::from(move |_| {
+ use rand::distr;
+ 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..29b5d731867
--- /dev/null
+++ b/examples/axum_ssr_router/tests/e2e.rs
@@ -0,0 +1,31 @@
+#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
+
+use axum_ssr_router::{App, AppProps, LINK_ENDPOINT};
+use ssr_e2e_harness::{
+ assert_hydrate_home, assert_ssr_hydration_and_client_navigation, output_element,
+};
+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 make_renderer() -> Renderer {
+ Renderer::::with_root_and_props(
+ output_element(),
+ AppProps {
+ endpoint: format!("{SERVER_BASE}{LINK_ENDPOINT}").into(),
+ },
+ )
+}
+
+#[wasm_bindgen_test]
+async fn ssr_hydration_and_client_navigation() {
+ assert_ssr_hydration_and_client_navigation(make_renderer, SERVER_BASE, LINK_ENDPOINT).await;
+}
+
+#[wasm_bindgen_test]
+async fn hydrate_home() {
+ assert_hydrate_home(make_renderer, SERVER_BASE).await;
+}
diff --git a/examples/function_router/src/content.rs b/examples/function_router/src/content.rs
index 2a9ab46132e..8834f66694f 100644
--- a/examples/function_router/src/content.rs
+++ b/examples/function_router/src/content.rs
@@ -1,6 +1,8 @@
+use serde::{Deserialize, Serialize};
+
use crate::generator::{Generated, Generator};
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Author {
pub seed: u32,
pub name: String,
@@ -22,7 +24,7 @@ impl Generated for Author {
}
}
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PostMeta {
pub seed: u32,
pub title: String,
@@ -48,7 +50,7 @@ impl Generated for PostMeta {
}
}
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Post {
pub meta: PostMeta,
pub content: Vec,
@@ -68,7 +70,7 @@ impl Generated for Post {
}
}
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum PostPart {
Section(Section),
Quote(Quote),
@@ -87,7 +89,7 @@ impl Generated for PostPart {
}
}
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Section {
pub title: String,
pub paragraphs: Vec,
@@ -112,7 +114,7 @@ impl Generated for Section {
}
}
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Quote {
pub author: Author,
pub content: String,
diff --git a/examples/function_router/src/lib.rs b/examples/function_router/src/lib.rs
index ad349dca7b0..e1679e7b0a4 100644
--- a/examples/function_router/src/lib.rs
+++ b/examples/function_router/src/lib.rs
@@ -14,11 +14,11 @@
// Hence, it may not yield the same value on the client and server side.
mod app;
-mod components;
-mod content;
+pub mod components;
+pub mod content;
mod generator;
pub mod imagegen;
-mod pages;
+pub mod pages;
pub use app::*;
pub use content::*;
diff --git a/examples/ssr_router/src/lib.rs b/examples/ssr_router/src/lib.rs
deleted file mode 100644
index 8b137891791..00000000000
--- a/examples/ssr_router/src/lib.rs
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/examples/ssr_router/tests/e2e.rs b/examples/ssr_router/tests/e2e.rs
deleted file mode 100644
index 73ba7bb29d1..00000000000
--- a/examples/ssr_router/tests/e2e.rs
+++ /dev/null
@@ -1,72 +0,0 @@
-#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
-
-use std::time::Duration;
-
-use function_router::App;
-use gloo::utils::document;
-use ssr_e2e_harness::{output_element, setup_ssr_page, wait_for};
-use wasm_bindgen_test::*;
-use yew::platform::time::sleep;
-
-wasm_bindgen_test_configure!(run_in_browser);
-
-const SERVER_BASE: &str = "http://127.0.0.1:8080";
-
-fn get_title_text() -> Option {
- document()
- .query_selector("h1.title")
- .ok()
- .flatten()
- .map(|el| el.text_content().unwrap_or_default())
-}
-
-#[wasm_bindgen_test]
-async fn hydrate_post_page() {
- setup_ssr_page(SERVER_BASE, "/posts/0").await;
- yew::Renderer::::with_root(output_element()).hydrate();
-
- wait_for(
- || {
- let html = output_element().inner_html();
- html.contains("") && !html.contains("Loading post...")
- },
- 5000,
- "post page content",
- )
- .await;
-
- let title = get_title_text().expect("h1.title should be present on the post page");
- assert!(!title.is_empty(), "post title should not be empty");
-}
-
-#[wasm_bindgen_test]
-async fn hydrate_posts_list() {
- setup_ssr_page(SERVER_BASE, "/posts").await;
- yew::Renderer::::with_root(output_element()).hydrate();
-
- wait_for(
- || {
- document()
- .query_selector("a.title.is-block")
- .ok()
- .flatten()
- .is_some()
- },
- 10000,
- "post links to appear on /posts",
- )
- .await;
-}
-
-#[wasm_bindgen_test]
-async fn hydrate_home() {
- setup_ssr_page(SERVER_BASE, "/").await;
- yew::Renderer::::with_root(output_element()).hydrate();
-
- sleep(Duration::from_secs(2)).await;
- let html = output_element().inner_html();
- assert!(
- html.contains("Welcome"),
- "home page should have content after hydration"
- );
-}
diff --git a/packages/yew-link-macro/Cargo.toml b/packages/yew-link-macro/Cargo.toml
new file mode 100644
index 00000000000..8424385614b
--- /dev/null
+++ b/packages/yew-link-macro/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "yew-link-macro"
+version = "0.1.0"
+edition = "2024"
+rust-version.workspace = true
+description = "Proc macros for yew-link"
+
+[lib]
+proc-macro = true
+
+[dependencies]
+proc-macro2 = { workspace = true }
+quote = { workspace = true }
+syn = { workspace = true, features = ["full"] }
+
+[lints]
+workspace = true
diff --git a/packages/yew-link-macro/release.toml b/packages/yew-link-macro/release.toml
new file mode 100644
index 00000000000..8f141163faa
--- /dev/null
+++ b/packages/yew-link-macro/release.toml
@@ -0,0 +1 @@
+tag = false
diff --git a/packages/yew-link-macro/src/lib.rs b/packages/yew-link-macro/src/lib.rs
new file mode 100644
index 00000000000..0a4fc672f16
--- /dev/null
+++ b/packages/yew-link-macro/src/lib.rs
@@ -0,0 +1,149 @@
+use proc_macro::TokenStream;
+use proc_macro2::Span;
+use quote::quote;
+use syn::{FnArg, Ident, ImplItem, ImplItemFn, ItemImpl, Pat, PatType, parse_macro_input};
+
+/// Derive a [`LinkedState`] implementation from an impl block that declares
+/// `type Context`, `type Input`, and `async fn resolve`.
+///
+/// On all targets the macro emits `impl LinkedState for T { type Input = …; type Error = …; }`.
+///
+/// On the server (`not(target_arch = "wasm32")`) it additionally emits
+/// `impl LinkedStateResolve for T { … }` with the user-provided resolve body.
+/// This half is stripped from WASM bundles automatically.
+///
+/// ## `type Error` (optional)
+///
+/// If `type Error` is omitted, it defaults to [`yew_link::Never`] (an uninhabited
+/// serde-compatible error type) and the resolve body is wrapped in `Ok(…)`
+/// automatically. When `type Error` is present, the resolve body must return
+/// `Result`.
+///
+/// # Example
+///
+/// ```ignore
+/// #[linked_state]
+/// impl LinkedState for Post {
+/// type Context = DbPool;
+/// type Input = u32;
+///
+/// async fn resolve(ctx: &DbPool, id: &u32) -> Self {
+/// ctx.get_post(*id).await
+/// }
+/// }
+///
+/// // With a typed error:
+/// #[linked_state]
+/// impl LinkedState for Post {
+/// type Context = DbPool;
+/// type Input = u32;
+/// type Error = ApiError;
+///
+/// async fn resolve(ctx: &DbPool, id: &u32) -> Result {
+/// ctx.get_post(*id).await.map_err(ApiError::from)
+/// }
+/// }
+/// ```
+#[proc_macro_attribute]
+pub fn linked_state(_attr: TokenStream, item: TokenStream) -> TokenStream {
+ let impl_block = parse_macro_input!(item as ItemImpl);
+ match expand(impl_block) {
+ Ok(tokens) => tokens.into(),
+ Err(err) => err.to_compile_error().into(),
+ }
+}
+
+fn expand(impl_block: ItemImpl) -> syn::Result {
+ let self_ty = &impl_block.self_ty;
+ let (impl_generics, ty_generics, where_clause) = impl_block.generics.split_for_impl();
+
+ let mut input_ty = None;
+ let mut context_ty = None;
+ let mut error_ty: Option<&syn::Type> = None;
+ let mut resolve_fn: Option<&ImplItemFn> = None;
+
+ for item in &impl_block.items {
+ match item {
+ ImplItem::Type(t) if t.ident == "Input" => input_ty = Some(&t.ty),
+ ImplItem::Type(t) if t.ident == "Context" => context_ty = Some(&t.ty),
+ ImplItem::Type(t) if t.ident == "Error" => error_ty = Some(&t.ty),
+ ImplItem::Fn(f) if f.sig.ident == "resolve" => resolve_fn = Some(f),
+ other => {
+ return Err(syn::Error::new_spanned(
+ other,
+ "#[linked_state] expects only `type Input`, `type Context`, `type Error` \
+ (optional), and `async fn resolve`",
+ ));
+ }
+ }
+ }
+
+ let input_ty =
+ input_ty.ok_or_else(|| syn::Error::new(Span::call_site(), "missing `type Input`"))?;
+ let context_ty =
+ context_ty.ok_or_else(|| syn::Error::new(Span::call_site(), "missing `type Context`"))?;
+ let resolve_fn = resolve_fn
+ .ok_or_else(|| syn::Error::new(Span::call_site(), "missing `async fn resolve`"))?;
+
+ if resolve_fn.sig.asyncness.is_none() {
+ return Err(syn::Error::new_spanned(
+ resolve_fn.sig.fn_token,
+ "`resolve` must be an async fn",
+ ));
+ }
+
+ let params: Vec<_> = resolve_fn.sig.inputs.iter().collect();
+ if params.len() != 2 {
+ return Err(syn::Error::new_spanned(
+ &resolve_fn.sig.inputs,
+ "`resolve` must take exactly two parameters: context and input references",
+ ));
+ }
+
+ let ctx_name = param_ident(params[0])?;
+ let input_name = param_ident(params[1])?;
+
+ let resolve_stmts = &resolve_fn.block.stmts;
+
+ let (error_ty_tokens, resolve_body) = match error_ty {
+ Some(ty) => (quote! { #ty }, quote! { #(#resolve_stmts)* }),
+ None => (
+ quote! { ::yew_link::Never },
+ quote! { ::core::result::Result::Ok({ #(#resolve_stmts)* }) },
+ ),
+ };
+
+ Ok(quote! {
+ impl #impl_generics ::yew_link::LinkedState for #self_ty #ty_generics #where_clause {
+ type Input = #input_ty;
+ type Error = #error_ty_tokens;
+ const TYPE_KEY: &'static str =
+ ::core::concat!(::core::module_path!(), "::", ::core::stringify!(#self_ty));
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ impl #impl_generics ::yew_link::LinkedStateResolve for #self_ty #ty_generics #where_clause {
+ type Context = #context_ty;
+
+ async fn resolve<'__yew_link>(
+ #ctx_name: &'__yew_link Self::Context,
+ #input_name: &'__yew_link ::Input,
+ ) -> ::core::result::Result::Error> {
+ #resolve_body
+ }
+ }
+ })
+}
+
+fn param_ident(arg: &FnArg) -> syn::Result<&Ident> {
+ match arg {
+ FnArg::Typed(PatType { pat, .. }) => match pat.as_ref() {
+ Pat::Ident(pi) => Ok(&pi.ident),
+ _ => Err(syn::Error::new_spanned(pat, "expected an identifier")),
+ },
+ other => Err(syn::Error::new_spanned(
+ other,
+ "unexpected `self` parameter",
+ )),
+ }
+}
diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml
new file mode 100644
index 00000000000..6b801a6c854
--- /dev/null
+++ b/packages/yew-link/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "yew-link"
+version = "0.1.0"
+edition = "2024"
+rust-version.workspace = true
+description = "Server-client state linking for Yew SSR"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+yew = { path = "../yew" }
+yew-link-macro = { path = "../yew-link-macro" }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+gloo-net = { version = "0.6", features = ["http"] }
+lru = "0.16"
+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
new file mode 100644
index 00000000000..2938d577826
--- /dev/null
+++ b/packages/yew-link/src/lib.rs
@@ -0,0 +1,684 @@
+use std::any::{Any, TypeId};
+use std::cell::RefCell;
+use std::collections::HashMap;
+#[cfg(target_arch = "wasm32")]
+use std::collections::HashSet;
+use std::fmt;
+use std::hash::Hash;
+#[cfg(target_arch = "wasm32")]
+use std::num::NonZeroUsize;
+use std::pin::Pin;
+use std::rc::Rc;
+use std::sync::Arc;
+
+#[cfg(target_arch = "wasm32")]
+use lru::LruCache;
+use serde::Serialize;
+use serde::de::DeserializeOwned;
+use yew::prelude::*;
+#[cfg(target_arch = "wasm32")]
+use yew::suspense::Suspension;
+use yew::suspense::SuspensionResult;
+pub use yew_link_macro::linked_state;
+
+/// A type that can be resolved on the server and transferred to the client.
+///
+/// Implementors declare an `Input` type and an `Error` type. The actual resolve
+/// logic is provided separately via [`Resolver::register`] or the
+/// [`#[linked_state]`](linked_state) macro.
+pub trait LinkedState: Serialize + DeserializeOwned + Clone + 'static {
+ /// The input/deps used to look up this state.
+ type Input: Serialize + DeserializeOwned + PartialEq + Eq + Hash + Clone + fmt::Debug + 'static;
+
+ /// Application-level error returned by a failed resolve.
+ type Error: Serialize + DeserializeOwned + Clone + fmt::Debug + fmt::Display + 'static;
+
+ /// Stable wire-format key used to route requests between client and server.
+ ///
+ /// Generated automatically by [`#[linked_state]`](linked_state) as
+ /// `concat!(module_path!(), "::", stringify!(Type))`. If you implement
+ /// `LinkedState` manually (e.g. for generic types), set this to a
+ /// string that is identical across server and client builds.
+ const TYPE_KEY: &'static str;
+}
+
+/// Server-side extension of [`LinkedState`] that provides a resolve function.
+///
+/// You normally don't implement this by hand — use the [`#[linked_state]`](linked_state)
+/// attribute macro instead. The macro generates this impl (gated behind
+/// `#[cfg(not(target_arch = "wasm32"))]`) and strips the server code from
+/// WASM bundles automatically.
+#[cfg(not(target_arch = "wasm32"))]
+pub trait LinkedStateResolve: LinkedState {
+ type Context: Send + Sync + 'static;
+
+ fn resolve<'a>(
+ ctx: &'a Self::Context,
+ input: &'a Self::Input,
+ ) -> impl Future> + Send + 'a;
+}
+
+/// An uninhabited error type that implements `Serialize`/`Deserialize`.
+///
+/// Used as the default `LinkedState::Error` when `type Error` is omitted from
+/// the `#[linked_state]` macro. Unlike `std::convert::Infallible`, this type
+/// satisfies the serde bounds required by the trait.
+#[derive(Clone, Debug, Serialize, serde::Deserialize)]
+pub enum Never {}
+
+impl fmt::Display for Never {
+ fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match *self {}
+ }
+}
+
+/// Error returned by [`use_linked_state`].
+///
+/// Distinguishes application-level errors (from the resolve function) from
+/// infrastructure failures (network, serialization, missing resolver).
+#[derive(Serialize, serde::Deserialize, Clone, Debug)]
+pub enum LinkError {
+ /// The resolve function returned an application-level error.
+ Resolve(E),
+ /// Infrastructure failure (network, serialization, missing resolver).
+ Internal(String),
+}
+
+impl fmt::Display for LinkError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Resolve(e) => fmt::Display::fmt(e, f),
+ Self::Internal(s) => f.write_str(s),
+ }
+ }
+}
+
+/// Handle returned by [`use_linked_state`].
+///
+/// Provides access to the resolved data, a [`refresh`](Self::refresh)
+/// method that triggers a background re-fetch, and an
+/// [`is_refreshing`](Self::is_refreshing) method to check whether a refresh
+/// is in progress (stale-while-revalidate).
+#[derive(Clone)]
+pub struct LinkedStateHandle {
+ result: Result, LinkError>,
+ refresh: Callback<()>,
+ refreshing: bool,
+}
+
+impl LinkedStateHandle {
+ /// Returns the resolved value, panicking if the resolver returned an error.
+ ///
+ /// This clones the inner [`Rc`], which is cheap.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the resolver returned a [`LinkError`]. Use
+ /// [`as_result`](Self::as_result) for non-panicking access.
+ pub fn data(&self) -> Rc {
+ self.result.as_ref().unwrap().clone()
+ }
+
+ /// Returns a reference to the underlying result.
+ pub fn as_result(&self) -> &Result, LinkError> {
+ &self.result
+ }
+
+ /// Triggers a background re-fetch of this linked state.
+ ///
+ /// Unlike a full suspend, the component keeps displaying the previous
+ /// (stale) value while the fresh data is being fetched. Use
+ /// [`is_refreshing`](Self::is_refreshing) to show a loading indicator
+ /// alongside the stale data.
+ ///
+ /// On the server this is a no-op.
+ pub fn refresh(&self) {
+ self.refresh.emit(());
+ }
+
+ /// Returns `true` while a background refresh is in progress and this
+ /// handle still holds the previous (stale) value.
+ pub fn is_refreshing(&self) -> bool {
+ self.refreshing
+ }
+}
+
+#[doc(hidden)]
+#[derive(Serialize, serde::Deserialize)]
+pub struct LinkRequest {
+ type_key: String,
+ input: serde_json::Value,
+}
+
+#[doc(hidden)]
+#[derive(Serialize, serde::Deserialize)]
+pub struct LinkResponse {
+ #[serde(default)]
+ ok: Option,
+ #[serde(default)]
+ error: Option,
+}
+
+type ResolveBoxFuture =
+ Pin> + Send>>;
+type ResolverFn = Box ResolveBoxFuture + Send + Sync>;
+
+/// Registry of resolve functions, keyed by [`std::any::type_name`].
+///
+/// Constructed on the server and passed to [`LinkProvider`]. Also used by the
+/// axum handler when the `axum` feature is enabled.
+pub struct Resolver {
+ handlers: HashMap<&'static str, ResolverFn>,
+}
+
+impl fmt::Debug for Resolver {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("Resolver")
+ .field("types", &self.handlers.keys().collect::>())
+ .finish()
+ }
+}
+
+impl Resolver {
+ pub fn new() -> Self {
+ Self {
+ handlers: HashMap::new(),
+ }
+ }
+
+ /// Register a resolver for `T`. The closure receives `T::Input` and returns
+ /// a future that produces `Result`.
+ pub fn register(mut self, f: F) -> Self
+ where
+ T: LinkedState + Send,
+ T::Error: Send,
+ F: Fn(T::Input) -> Fut + Send + Sync + 'static,
+ Fut: Future> + Send + 'static,
+ {
+ self.handlers.insert(
+ T::TYPE_KEY,
+ Box::new(move |input_json: serde_json::Value| {
+ let input: T::Input = match serde_json::from_value(input_json) {
+ Ok(v) => v,
+ Err(e) => {
+ return Box::pin(async move {
+ Err(serde_json::Value::String(format!(
+ "failed to deserialize input: {e}"
+ )))
+ });
+ }
+ };
+ let fut = f(input);
+ Box::pin(async move {
+ match fut.await {
+ Ok(val) => serde_json::to_value(&val)
+ .map_err(|e| serde_json::Value::String(e.to_string())),
+ Err(e) => Err(serde_json::to_value(&e).unwrap_or_else(|ser_err| {
+ serde_json::Value::String(format!(
+ "{e}: (serialization failed: {ser_err})"
+ ))
+ })),
+ }
+ })
+ }),
+ );
+ self
+ }
+
+ /// Resolve a [`LinkRequest`].
+ pub async fn resolve_request(
+ &self,
+ req: &LinkRequest,
+ ) -> Result {
+ let handler = self.handlers.get(req.type_key.as_str()).ok_or_else(|| {
+ serde_json::Value::String(format!("no resolver registered for {}", req.type_key))
+ })?;
+ handler(req.input.clone()).await
+ }
+}
+
+impl Default for Resolver {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+impl Resolver {
+ /// Register a resolver for `T` using its [`LinkedStateResolve`] impl.
+ ///
+ /// The `ctx` is wrapped in an [`Arc`] internally so clones are cheap.
+ pub fn register_linked(self, ctx: T::Context) -> Self
+ where
+ T: LinkedStateResolve + Send + 'static,
+ T::Input: Send,
+ T::Error: Send,
+ {
+ let ctx = Arc::new(ctx);
+ self.register::(move |input| {
+ let ctx = ctx.clone();
+ async move { T::resolve(&*ctx, &input).await }
+ })
+ }
+}
+
+#[derive(Clone)]
+struct CacheKey {
+ type_id: TypeId,
+ input_hash: u64,
+ input: Rc,
+ eq_fn: fn(&dyn Any, &dyn Any) -> bool,
+}
+
+impl Hash for CacheKey {
+ fn hash(&self, state: &mut H) {
+ self.type_id.hash(state);
+ self.input_hash.hash(state);
+ }
+}
+
+impl PartialEq for CacheKey {
+ fn eq(&self, other: &Self) -> bool {
+ self.type_id == other.type_id && (self.eq_fn)(&*self.input, &*other.input)
+ }
+}
+
+impl Eq for CacheKey {}
+
+#[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))]
+fn eq_inputs(a: &dyn Any, b: &dyn Any) -> bool {
+ a.downcast_ref::()
+ .zip(b.downcast_ref::())
+ .is_some_and(|(a, b)| a == b)
+}
+
+#[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))]
+fn cache_key(input: &T::Input) -> CacheKey {
+ use std::hash::Hasher;
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ input.hash(&mut hasher);
+ CacheKey {
+ type_id: TypeId::of::(),
+ input_hash: hasher.finish(),
+ input: Rc::new(input.clone()),
+ eq_fn: eq_inputs::,
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+type Cache = Rc>>;
+#[cfg(not(target_arch = "wasm32"))]
+type Cache = Rc>>;
+
+#[cfg(target_arch = "wasm32")]
+type InFlight = Rc>>;
+
+#[cfg(target_arch = "wasm32")]
+type Refreshing = Rc>>;
+
+#[derive(Clone)]
+struct LinkContextInner {
+ cache: Cache,
+ #[cfg(target_arch = "wasm32")]
+ in_flight: InFlight,
+ #[cfg(target_arch = "wasm32")]
+ refreshing: Refreshing,
+ endpoint: AttrValue,
+ #[cfg(feature = "ssr")]
+ resolver: Option>,
+}
+
+impl PartialEq for LinkContextInner {
+ fn eq(&self, other: &Self) -> bool {
+ Rc::ptr_eq(&self.cache, &other.cache) && self.endpoint == other.endpoint && {
+ #[cfg(target_arch = "wasm32")]
+ {
+ Rc::ptr_eq(&self.in_flight, &other.in_flight)
+ && Rc::ptr_eq(&self.refreshing, &other.refreshing)
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ true
+ }
+ }
+ }
+}
+
+impl LinkContextInner {
+ #[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))]
+ async fn fetch_remote(
+ &self,
+ input: &T::Input,
+ ) -> Result> {
+ use gloo_net::http::Request;
+
+ let req_body = LinkRequest {
+ type_key: T::TYPE_KEY.to_string(),
+ input: serde_json::to_value(input).map_err(|e| LinkError::Internal(e.to_string()))?,
+ };
+ let resp = Request::post(self.endpoint.as_ref())
+ .json(&req_body)
+ .map_err(|e| LinkError::Internal(e.to_string()))?
+ .send()
+ .await
+ .map_err(|e| LinkError::Internal(e.to_string()))?;
+
+ let link_resp: LinkResponse = resp
+ .json()
+ .await
+ .map_err(|e| LinkError::Internal(e.to_string()))?;
+
+ match link_resp.ok {
+ Some(val) => {
+ serde_json::from_value(val).map_err(|e| LinkError::Internal(e.to_string()))
+ }
+ None => match link_resp.error {
+ Some(err_val) => {
+ let e: T::Error = serde_json::from_value(err_val)
+ .map_err(|e| LinkError::Internal(e.to_string()))?;
+ Err(LinkError::Resolve(e))
+ }
+ None => Err(LinkError::Internal("unknown error".into())),
+ },
+ }
+ }
+
+ #[cfg(feature = "ssr")]
+ async fn resolve_local(
+ &self,
+ input: &T::Input,
+ ) -> Result> {
+ let resolver = self
+ .resolver
+ .as_ref()
+ .expect("resolver not set on server-side LinkProvider");
+ let req = LinkRequest {
+ type_key: T::TYPE_KEY.to_string(),
+ input: serde_json::to_value(input).map_err(|e| LinkError::Internal(e.to_string()))?,
+ };
+ match resolver.resolve_request(&req).await {
+ Ok(val) => serde_json::from_value(val).map_err(|e| LinkError::Internal(e.to_string())),
+ Err(err_val) => {
+ let e: T::Error = serde_json::from_value(err_val)
+ .map_err(|e| LinkError::Internal(e.to_string()))?;
+ Err(LinkError::Resolve(e))
+ }
+ }
+ }
+}
+
+/// Wrapper so [`Resolver`] can be passed as a component prop.
+///
+/// Uses `Arc` internally so it is `Send` (required by `ServerRenderer::with_props`).
+#[derive(Clone)]
+pub struct ResolverProp(pub Arc);
+
+impl fmt::Debug for ResolverProp {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("ResolverProp").field(&self.0).finish()
+ }
+}
+
+impl PartialEq for ResolverProp {
+ fn eq(&self, other: &Self) -> bool {
+ Arc::ptr_eq(&self.0, &other.0)
+ }
+}
+
+impl Eq for ResolverProp {}
+
+impl From for ResolverProp {
+ fn from(r: Resolver) -> Self {
+ Self(Arc::new(r))
+ }
+}
+
+#[derive(Properties, PartialEq, Clone)]
+pub struct LinkProviderProps {
+ pub children: Children,
+ /// Remote endpoint URL used by the client to fetch linked states.
+ #[prop_or_default]
+ pub endpoint: AttrValue,
+ /// Server-side resolver. Ignored on wasm32 targets.
+ #[prop_or_default]
+ pub resolver: Option,
+ /// Maximum number of entries in the linked-state cache. Defaults to 64.
+ #[prop_or(64)]
+ pub cache_capacity: usize,
+}
+
+/// Provides linked-state resolution context to descendant components.
+///
+/// On the server, pass a [`ResolverProp`] so that [`use_linked_state`] can
+/// resolve states locally. On the client, pass an `endpoint` URL.
+#[component]
+pub fn LinkProvider(props: &LinkProviderProps) -> Html {
+ #[cfg(target_arch = "wasm32")]
+ let cache: Cache = {
+ let cap = NonZeroUsize::new(props.cache_capacity).unwrap_or(NonZeroUsize::MIN);
+ (*use_ref(|| Rc::new(RefCell::new(LruCache::new(cap))))).clone()
+ };
+ #[cfg(not(target_arch = "wasm32"))]
+ let cache: Cache = (*use_ref(|| Rc::new(RefCell::new(HashMap::new())))).clone();
+ #[cfg(target_arch = "wasm32")]
+ let in_flight: InFlight = (*use_ref(|| Rc::new(RefCell::new(HashMap::new())))).clone();
+ #[cfg(target_arch = "wasm32")]
+ let refreshing: Refreshing = (*use_ref(|| Rc::new(RefCell::new(HashSet::new())))).clone();
+
+ let ctx = LinkContextInner {
+ cache,
+ #[cfg(target_arch = "wasm32")]
+ in_flight,
+ #[cfg(target_arch = "wasm32")]
+ refreshing,
+ endpoint: props.endpoint.clone(),
+ #[cfg(feature = "ssr")]
+ resolver: props.resolver.as_ref().map(|r| Arc::clone(&r.0)),
+ };
+
+ html! {
+ context={ctx}>
+ { for props.children.iter() }
+ >
+ }
+}
+
+/// Fetch a [`LinkedState`] value.
+///
+/// On the server the state is resolved locally via the [`Resolver`] registered
+/// in the ancestor [`LinkProvider`], and the result is embedded in the SSR
+/// HTML for zero-cost hydration on the client.
+///
+/// On the client during hydration, the SSR-embedded state is read directly
+/// without any network request. On subsequent client-side navigations the
+/// state is fetched from the provider's `endpoint` URL.
+///
+/// The hook suspends while the state is being resolved/fetched for the first
+/// time. On [`refresh`](LinkedStateHandle::refresh), the previous value is
+/// kept visible (stale-while-revalidate) and
+/// [`is_refreshing`](LinkedStateHandle::is_refreshing) returns `true` until
+/// the fresh data arrives.
+///
+/// Multiple components requesting the same `(T, Input)` concurrently share a
+/// single in-flight request.
+///
+/// # Panics
+///
+/// Panics if there is no ancestor [`LinkProvider`] in the component tree.
+#[hook]
+pub fn use_linked_state(input: T::Input) -> SuspensionResult> {
+ #[cfg(any(feature = "ssr", target_arch = "wasm32"))]
+ let link_ctx =
+ use_context::().expect("use_linked_state requires a LinkProvider");
+
+ #[cfg(any(feature = "ssr", target_arch = "wasm32"))]
+ type Prepared = Result>;
+
+ #[cfg(feature = "ssr")]
+ {
+ let prepared = {
+ let link_ctx = link_ctx.clone();
+ yew::functional::use_prepared_state_with_suspension(
+ input,
+ move |input: Rc| {
+ let link_ctx = link_ctx.clone();
+ async move { link_ctx.resolve_local::(&input).await }
+ },
+ )
+ }?;
+
+ let result: Rc> =
+ prepared.expect("prepared state should always be Some on SSR");
+ Ok(LinkedStateHandle {
+ result: match result.as_ref() {
+ Ok(val) => Ok(Rc::new(val.clone())),
+ Err(e) => Err(e.clone()),
+ },
+ refresh: Callback::from(|_: ()| {}),
+ refreshing: false,
+ })
+ }
+
+ #[cfg(all(not(feature = "ssr"), not(target_arch = "wasm32")))]
+ {
+ let _ = input;
+ Ok(LinkedStateHandle {
+ result: Err(LinkError::Internal(
+ "yew-link requires the `ssr` feature (server) or a wasm32 target (client)".into(),
+ )),
+ refresh: Callback::from(|_: ()| {}),
+ refreshing: false,
+ })
+ }
+
+ #[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))]
+ {
+ let prepared =
+ yew::functional::use_prepared_state::, T::Input>(input.clone())?;
+
+ let key = cache_key::(&input);
+
+ if let Some(ref result) = prepared {
+ if let Ok(json_val) = serde_json::to_value(result.as_ref()) {
+ let mut cache = link_ctx.cache.borrow_mut();
+ if cache.peek(&key).is_none() {
+ cache.put(key.clone(), json_val);
+ }
+ }
+ }
+
+ let force_update = use_force_update();
+ let has_refreshed = use_ref(|| std::cell::Cell::new(false));
+
+ let refresh = {
+ let cache = link_ctx.cache.clone();
+ let refreshing = link_ctx.refreshing.clone();
+ let key = key.clone();
+ let link_ctx = link_ctx.clone();
+ let input = input.clone();
+ let force_update = force_update.clone();
+ let has_refreshed = has_refreshed.clone();
+ Callback::from(move |()| {
+ refreshing.borrow_mut().insert(key.clone());
+ has_refreshed.set(true);
+
+ let link_ctx = link_ctx.clone();
+ let key = key.clone();
+ let cache = cache.clone();
+ let refreshing = refreshing.clone();
+ let inner_force_update = force_update.clone();
+ let input = input.clone();
+ wasm_bindgen_futures::spawn_local(async move {
+ let result: Result> =
+ link_ctx.fetch_remote::(&input).await;
+
+ refreshing.borrow_mut().remove(&key);
+
+ let should_cache = match &result {
+ Ok(_) | Err(LinkError::Resolve(_)) => true,
+ Err(LinkError::Internal(_)) => false,
+ };
+ if should_cache {
+ if let Ok(json_val) = serde_json::to_value(&result) {
+ cache.borrow_mut().put(key, json_val);
+ }
+ }
+
+ inner_force_update.force_update();
+ });
+
+ force_update.force_update();
+ })
+ };
+
+ if !has_refreshed.get() {
+ if let Some(result) = prepared {
+ return Ok(LinkedStateHandle {
+ result: match result.as_ref() {
+ Ok(val) => Ok(Rc::new(val.clone())),
+ Err(e) => Err(e.clone()),
+ },
+ refresh,
+ refreshing: false,
+ });
+ }
+ }
+
+ let is_refreshing = link_ctx.refreshing.borrow().contains(&key);
+
+ if let Some(cached_val) = link_ctx.cache.borrow_mut().get(&key).cloned() {
+ if let Ok(result) = serde_json::from_value::>(cached_val) {
+ return Ok(LinkedStateHandle {
+ result: result.map(Rc::new),
+ refresh,
+ refreshing: is_refreshing,
+ });
+ }
+ }
+
+ if let Some(sus) = link_ctx.in_flight.borrow().get(&key).cloned() {
+ if !sus.resumed() {
+ return Err(sus);
+ }
+ if let Some(cached_val) = link_ctx.cache.borrow_mut().get(&key).cloned() {
+ if let Ok(result) = serde_json::from_value::>(cached_val) {
+ return Ok(LinkedStateHandle {
+ result: result.map(Rc::new),
+ refresh,
+ refreshing: false,
+ });
+ }
+ }
+ }
+
+ let sus = Suspension::from_future({
+ let link_ctx = link_ctx.clone();
+ let key = key.clone();
+ async move {
+ let result: Result> =
+ link_ctx.fetch_remote::(&input).await;
+
+ link_ctx.in_flight.borrow_mut().remove(&key);
+
+ let should_cache = match &result {
+ Ok(_) | Err(LinkError::Resolve(_)) => true,
+ Err(LinkError::Internal(_)) => false,
+ };
+ if should_cache {
+ if let Ok(json_val) = serde_json::to_value(&result) {
+ link_ctx.cache.borrow_mut().put(key, json_val);
+ }
+ }
+ }
+ });
+
+ link_ctx.in_flight.borrow_mut().insert(key, sus.clone());
+ Err(sus)
+ }
+}
+
+#[cfg(all(not(target_arch = "wasm32"), any(feature = "axum", feature = "actix")))]
+mod services;
+
+#[cfg(all(not(target_arch = "wasm32"), any(feature = "axum", feature = "actix")))]
+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..a06f15706ee
--- /dev/null
+++ b/packages/yew-link/src/services.rs
@@ -0,0 +1,134 @@
+#[cfg(feature = "axum")]
+pub mod axum {
+ use std::sync::Arc;
+
+ use axum::Json;
+ use axum::extract::State;
+ use axum::http::StatusCode;
+ use axum::response::IntoResponse;
+
+ use crate::{LinkRequest, LinkResponse, Resolver};
+
+ /// Axum handler that resolves [`LinkRequest`]s.
+ ///
+ /// ```
+ /// use std::sync::Arc;
+ ///
+ /// use serde::{Deserialize, Serialize};
+ /// use yew_link::axum::linked_state_handler;
+ /// use yew_link::{LinkedState, Never, Resolver};
+ ///
+ /// #[derive(Clone, Debug, Serialize, Deserialize)]
+ /// struct Post {
+ /// title: String,
+ /// }
+ ///
+ /// impl LinkedState for Post {
+ /// type Error = Never;
+ /// type Input = u32;
+ ///
+ /// const TYPE_KEY: &'static str = "Post";
+ /// }
+ ///
+ /// async fn get_post(_id: u32) -> Result {
+ /// Ok(Post {
+ /// title: String::new(),
+ /// })
+ /// }
+ ///
+ /// let resolver = Arc::new(Resolver::new().register::(|id| get_post(id)));
+ ///
+ /// let app: axum::Router = axum::Router::new().route(
+ /// "/api/link",
+ /// axum::routing::post(linked_state_handler).with_state(resolver),
+ /// );
+ /// # let _ = app;
+ /// ```
+ 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(feature = "actix")]
+pub mod actix {
+ use actix_web::HttpResponse;
+ use actix_web::web::{Data, Json};
+
+ use crate::{LinkRequest, LinkResponse, Resolver};
+
+ /// Actix handler that resolves [`LinkRequest`]s.
+ ///
+ /// ```no_run
+ /// use actix_web::web::{Data, post};
+ /// use actix_web::{App, HttpServer};
+ /// use serde::{Deserialize, Serialize};
+ /// use yew_link::actix::linked_state_handler;
+ /// use yew_link::{LinkedState, Never, Resolver};
+ ///
+ /// #[derive(Clone, Debug, Serialize, Deserialize)]
+ /// struct Post {
+ /// title: String,
+ /// }
+ ///
+ /// impl LinkedState for Post {
+ /// type Error = Never;
+ /// type Input = u32;
+ ///
+ /// const TYPE_KEY: &'static str = "Post";
+ /// }
+ ///
+ /// async fn get_post(_id: u32) -> Result {
+ /// Ok(Post {
+ /// title: String::new(),
+ /// })
+ /// }
+ ///
+ /// #[actix_web::main]
+ /// async fn main() -> std::io::Result<()> {
+ /// let resolver = Data::new(Resolver::new().register::(|id| get_post(id)));
+ ///
+ /// HttpServer::new(move || {
+ /// App::new()
+ /// .app_data(resolver.clone())
+ /// .route("/api/link", post().to(linked_state_handler))
+ /// })
+ /// .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/packages/yew-router/Cargo.toml b/packages/yew-router/Cargo.toml
index 13181ab421f..0fab5a1f522 100644
--- a/packages/yew-router/Cargo.toml
+++ b/packages/yew-router/Cargo.toml
@@ -33,7 +33,7 @@ features = [
]
[dev-dependencies]
-wasm-bindgen-test = "0.3"
+wasm-bindgen-test.workspace = true
serde = { workspace = true, features = ["derive"] }
yew = { version = "0.23.0", path = "../yew", features = ["csr"] }
diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml
index 95d5244c8d4..629e50f9ccc 100644
--- a/packages/yew/Cargo.toml
+++ b/packages/yew/Cargo.toml
@@ -85,7 +85,7 @@ tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
[dev-dependencies]
-wasm-bindgen-test = "0.3"
+wasm-bindgen-test.workspace = true
gloo = { workspace = true, features = ["futures"] }
wasm-bindgen-futures.workspace = true
trybuild = { workspace = true }
diff --git a/packages/yew/tests/hydration.rs b/packages/yew/tests/hydration.rs
index be29a2056ea..63ca2144edd 100644
--- a/packages/yew/tests/hydration.rs
+++ b/packages/yew/tests/hydration.rs
@@ -1107,7 +1107,7 @@ async fn hydrate_flicker() {
#[wasm_bindgen_test]
async fn hydration_with_camelcase_svg_elements() {
- #[function_component]
+ #[component]
fn SvgWithCamelCase() -> Html {
html! {
@@ -1131,7 +1131,7 @@ async fn hydration_with_camelcase_svg_elements() {
}
}
- #[function_component]
+ #[component]
fn App() -> Html {
let counter = use_state(|| 0);
let onclick = {
diff --git a/tools/changelog/src/yew_package.rs b/tools/changelog/src/yew_package.rs
index 30278c993d0..c7f467145b6 100644
--- a/tools/changelog/src/yew_package.rs
+++ b/tools/changelog/src/yew_package.rs
@@ -6,6 +6,7 @@ pub enum YewPackage {
Yew,
YewAgent,
YewRouter,
+ YewLink,
}
impl YewPackage {
@@ -14,6 +15,7 @@ impl YewPackage {
YewPackage::Yew => &["A-yew", "A-yew-macro", "macro"],
YewPackage::YewAgent => &["A-yew-agent"],
YewPackage::YewRouter => &["A-yew-router", "A-yew-router-macro"],
+ YewPackage::YewLink => &["A-yew-link", "A-yew-link-macro"],
}
}
}
diff --git a/tools/ssr-e2e-harness/Cargo.toml b/tools/ssr-e2e-harness/Cargo.toml
index d2dc8fbc28a..3c1ec539385 100644
--- a/tools/ssr-e2e-harness/Cargo.toml
+++ b/tools/ssr-e2e-harness/Cargo.toml
@@ -6,5 +6,6 @@ rust-version.workspace = true
[dependencies]
gloo = { workspace = true, features = ["futures"] }
-web-sys = { workspace = true }
+web-sys = { workspace = true, features = ["Performance", "PerformanceEntry"] }
wasm-bindgen = { workspace = true }
+yew = { path = "../../packages/yew", features = ["csr", "hydration", "test"] }
diff --git a/tools/ssr-e2e-harness/src/lib.rs b/tools/ssr-e2e-harness/src/lib.rs
index 6309557cd9c..b36dd9a0fef 100644
--- a/tools/ssr-e2e-harness/src/lib.rs
+++ b/tools/ssr-e2e-harness/src/lib.rs
@@ -2,6 +2,8 @@ use std::time::Duration;
use gloo::utils::document;
use wasm_bindgen::prelude::*;
+use yew::Renderer;
+use yew::html::BaseComponent;
/// Returns the `` element used by wasm-bindgen-test as the
/// test output container.
@@ -56,3 +58,228 @@ pub fn push_route(path: &str) {
.push_state_with_url(&JsValue::NULL, "", Some(path))
.unwrap();
}
+
+fn performance() -> web_sys::Performance {
+ web_sys::window().unwrap().performance().unwrap()
+}
+
+/// Counts completed network requests to URLs containing `needle` using the
+/// Performance Resource Timing API. This works regardless of how the request
+/// was initiated (gloo-net, window.fetch, XMLHttpRequest, etc.) because it
+/// observes the browser's actual network activity.
+pub fn resource_request_count(needle: &str) -> u32 {
+ let entries = performance().get_entries_by_type("resource");
+ let mut count = 0;
+ for i in 0..entries.length() {
+ let entry: web_sys::PerformanceEntry = entries.get(i).unchecked_into();
+ if entry.name().contains(needle) {
+ count += 1;
+ }
+ }
+ count
+}
+
+/// Clears all resource timing entries so that subsequent calls to
+/// [`resource_request_count`] only see new requests.
+pub fn clear_resource_timings() {
+ performance().clear_resource_timings();
+}
+
+/// Returns the text content of the first `
` in the document.
+pub fn get_title_text() -> Option {
+ document()
+ .query_selector("h1.title")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+}
+
+/// Returns the text content of the first `.section.container` inside the
+/// test output element.
+pub fn post_body_text() -> String {
+ output_element()
+ .query_selector(".section.container")
+ .ok()
+ .flatten()
+ .map(|el| el.text_content().unwrap_or_default())
+ .unwrap_or_default()
+}
+
+/// Parses `html` into a detached container element and returns the text
+/// content of the first element matching `selector`, if any.
+pub 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())
+}
+
+/// Shared e2e scenario used by the yew-link SSR router examples.
+///
+/// Phases:
+/// 1. Directly visit `/posts/0` by fetching its SSR HTML, hydrate, and assert that hydration did
+/// not trigger any fetch to `link_endpoint`.
+/// 2. Click the "Posts" navbar link, then the post 0 card, and wait for the post page to render.
+/// 3. Assert at least one fetch to `link_endpoint` happened during the client-side navigation and
+/// that the rendered title/body match the original SSR HTML.
+///
+/// `make_renderer` is a closure that builds a `Renderer` rooted at
+/// [`output_element()`]. It is invoked after the SSR HTML has been injected
+/// so that hydration picks it up.
+pub async fn assert_ssr_hydration_and_client_navigation(
+ make_renderer: impl FnOnce() -> Renderer,
+ server_base: &str,
+ link_endpoint: &str,
+) where
+ COMP: BaseComponent,
+{
+ // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to link_endpoint --
+
+ 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);
+ 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;
+}
+
+/// Shared e2e scenario that asserts hydrating the home page ("/") produces
+/// HTML containing the word "Welcome".
+pub async fn assert_hydrate_home(
+ make_renderer: impl FnOnce() -> Renderer,
+ server_base: &str,
+) where
+ COMP: BaseComponent,
+{
+ 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/tools/ssr-e2e/Cargo.toml b/tools/ssr-e2e/Cargo.toml
index 6e2d2076660..652a4e62970 100644
--- a/tools/ssr-e2e/Cargo.toml
+++ b/tools/ssr-e2e/Cargo.toml
@@ -6,7 +6,7 @@ rust-version.workspace = true
[dependencies]
clap = { workspace = true }
-tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "time", "signal"] }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "time"] }
reqwest = { workspace = true }
[target.'cfg(unix)'.dependencies]
diff --git a/tools/ssr-e2e/src/main.rs b/tools/ssr-e2e/src/main.rs
index 667733643c6..64f4bcfab04 100644
--- a/tools/ssr-e2e/src/main.rs
+++ b/tools/ssr-e2e/src/main.rs
@@ -92,7 +92,9 @@ async fn wait_for_server(url: &str, timeout: Duration) -> bool {
fn shutdown_server(server: &mut Child) {
#[cfg(unix)]
if let Some(id) = server.id() {
- let _ = unsafe { libc::kill(-(id as i32), libc::SIGTERM) };
+ unsafe {
+ libc::kill(-(id as i32), libc::SIGTERM);
+ }
return;
}
@@ -143,6 +145,7 @@ async fn main() -> ExitCode {
let test_result = Command::new("cargo")
.args(&cargo_args)
.env("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION", "1")
+ .env("WASM_BINDGEN_TEST_NO_STREAM", "1")
.status()
.await;
diff --git a/tools/website-test/Cargo.toml b/tools/website-test/Cargo.toml
index f8e6deb75d3..193a921bec9 100644
--- a/tools/website-test/Cargo.toml
+++ b/tools/website-test/Cargo.toml
@@ -18,11 +18,17 @@ serde = { workspace = true, features = ["derive"] }
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
weblog = "0.3.0"
-yew = { path = "../../packages/yew/", features = ["ssr", "csr", "serde"] }
+yew = { path = "../../packages/yew/", features = ["ssr", "csr", "hydration", "serde"] }
yew-autoprops = "0.5.0"
yew-router = { path = "../../packages/yew-router/" }
tokio = { workspace = true, features = ["rt", "macros"] }
+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
+actix-web = { workspace = true }
+axum = { workspace = true }
+serde_json = { workspace = true }
+yew-link = { path = "../../packages/yew-link/", features = ["actix", "axum"] }
+
[dev-dependencies.web-sys]
workspace = true
features = [
diff --git a/tools/website-test/build.rs b/tools/website-test/build.rs
index b3b4b462306..217fe1e7714 100644
--- a/tools/website-test/build.rs
+++ b/tools/website-test/build.rs
@@ -183,7 +183,7 @@ impl Level {
if should_combine_code_blocks(file)? {
let res = combined_code_blocks(file)?;
self.write_space(dst, level);
- writeln!(dst, "/// ```rust, no_run")?;
+ writeln!(dst, "/// ```no_run")?;
for line in res.lines() {
self.write_space(dst, level);
writeln!(dst, "/// {line}")?;
diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx
index c31d1c668db..b2b5236fef6 100644
--- a/website/docs/advanced-topics/server-side-rendering.mdx
+++ b/website/docs/advanced-topics/server-side-rendering.mdx
@@ -128,6 +128,247 @@ suspended.
With this approach, developers can build a client-agnostic, SSR-ready
application with data fetching with very little effort.
+### Low-level hooks
+
+Yew ships two low-level hooks for carrying server-computed state to the client:
+
+- **`use_prepared_state!`** runs an (optionally async) closure during SSR, serializes the result, and delivers it to the client during hydration. Ideal for fetching data that the component needs on first render.
+- **`use_transitive_state!`** is similar, but the closure runs _after_ the component's SSR output is produced. Useful for collecting caches or aggregated state.
+
+Both use `bincode` + `base64` under the hood and are embedded in the HTML as `