From 2e2dd08dfef340784b17ad807af2586256e4485e Mon Sep 17 00:00:00 2001 From: stifskere Date: Sat, 4 Apr 2026 20:30:38 +0200 Subject: [PATCH 01/16] feat: add actix support for yew-link --- .gitignore | 1 + Cargo.lock | 563 +++++++++++++++++- Cargo.toml | 1 + .../ssr_router/src/bin/ssr_router_server.rs | 3 +- packages/yew-link/Cargo.toml | 2 + packages/yew-link/src/lib.rs | 51 +- packages/yew-link/src/services.rs | 87 +++ .../advanced-topics/server-side-rendering.mdx | 9 +- 8 files changed, 646 insertions(+), 71 deletions(-) create mode 100644 packages/yew-link/src/services.rs diff --git a/.gitignore b/.gitignore index bec5f8c71aa..d3244a16012 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ *.iml /.idea/ /.vscode/ +.nvim.lua diff --git a/Cargo.lock b/Cargo.lock index 057db7f0c56..59c3ca68212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,210 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "base64", + "bitflags 2.11.0", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash 0.1.5", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "impl-more", + "pin-project-lite", + "tokio", + "tokio-rustls 0.23.4", + "tokio-util", + "tracing", + "webpki-roots", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "adler2" version = "2.0.1" @@ -17,6 +221,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -352,6 +571,27 @@ dependencies = [ "yew", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "build-examples" version = "0.1.0" @@ -398,6 +638,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + [[package]] name = "cast" version = "0.3.0" @@ -609,6 +858,26 @@ dependencies = [ "yew-agent", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -776,10 +1045,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -991,6 +1262,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1428,6 +1705,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1464,7 +1760,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1501,7 +1797,7 @@ dependencies = [ "hash32", "rustc_version", "serde", - "spin", + "spin 0.9.8", "stable_deref_trait", ] @@ -1598,7 +1894,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", + "h2 0.4.13", "http 1.4.0", "http-body", "httparse", @@ -1620,10 +1916,10 @@ dependencies = [ "http 1.4.0", "hyper", "hyper-util", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -1644,7 +1940,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -1806,6 +2102,12 @@ dependencies = [ "yew", ] +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + [[package]] name = "implicit-clone" version = "0.6.0" @@ -2042,6 +2344,12 @@ dependencies = [ "yew", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -2121,6 +2429,23 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.14" @@ -2206,6 +2531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2335,6 +2661,29 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "password_strength" version = "0.1.0" @@ -2588,8 +2937,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.37", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2607,9 +2956,9 @@ dependencies = [ "getrandom 0.3.4", "lru-slab", "rand 0.9.2", - "ring", + "ring 0.17.14", "rustc-hash", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2627,7 +2976,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2706,6 +3055,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "regex" version = "1.12.3" @@ -2753,7 +3111,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.4.13", "http 1.4.0", "http-body", "http-body-util", @@ -2766,14 +3124,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -2783,6 +3141,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.14" @@ -2793,7 +3166,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2848,6 +3221,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +dependencies = [ + "log", + "ring 0.16.20", + "sct", + "webpki", +] + [[package]] name = "rustls" version = "0.23.37" @@ -2895,7 +3280,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls", + "rustls 0.23.37", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", @@ -2918,9 +3303,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", - "ring", + "ring 0.17.14", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2965,6 +3350,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "security-framework" version = "3.6.0" @@ -3144,6 +3539,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3154,6 +3559,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -3432,10 +3843,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3444,6 +3857,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "timer" version = "0.1.0" @@ -3510,9 +3933,10 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3528,13 +3952,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls 0.20.9", + "tokio", + "webpki", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -3770,18 +4205,36 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -4090,6 +4543,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring 0.17.14", + "untrusted 0.9.0", +] + [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -4099,6 +4562,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + [[package]] name = "website-test" version = "0.1.0" @@ -4120,6 +4592,22 @@ dependencies = [ "yew-router", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4129,6 +4617,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -4520,6 +5014,7 @@ dependencies = [ name = "yew-link" version = "0.1.0" dependencies = [ + "actix-web", "axum", "gloo-net 0.6.0", "lru", @@ -4717,6 +5212,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zxcvbn" version = "3.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7642f115493..f91afc42554 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,3 +60,4 @@ thiserror = "2.0" bincode = { version = "2.0.0-rc.3", features = ["serde"] } reqwest = "0.13" axum = "0.8" +actix-web = { version = "4.13.0", features = ["rustls", "macros"] } diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/ssr_router/src/bin/ssr_router_server.rs index 3112c032aa3..e0915821437 100644 --- a/examples/ssr_router/src/bin/ssr_router_server.rs +++ b/examples/ssr_router/src/bin/ssr_router_server.rs @@ -25,7 +25,8 @@ use tower::Service; use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use yew::platform::Runtime; -use yew_link::{linked_state_handler, Resolver, ResolverProp}; +use yew_link::axum::linked_state_handler; +use yew_link::{Resolver, ResolverProp}; use yew_router::prelude::Routable; // We use jemalloc as it produces better performance. diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml index 6960f30090a..7c1ec6a7965 100644 --- a/packages/yew-link/Cargo.toml +++ b/packages/yew-link/Cargo.toml @@ -20,12 +20,14 @@ wasm-bindgen-futures = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { workspace = true, optional = true } +actix-web = { workspace = true, optional = true } [features] default = [] ssr = ["yew/ssr"] hydration = ["yew/hydration"] axum = ["dep:axum"] +actix = ["dep:actix-web"] [lints] workspace = true diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs index 19adc8fb7d1..4e09dd807b7 100644 --- a/packages/yew-link/src/lib.rs +++ b/packages/yew-link/src/lib.rs @@ -679,51 +679,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult(|id| async move { db::get_post(id).await }) - /// ); - /// - /// let app = axum::Router::new() - /// .route("/api/link", axum::routing::post(linked_state_handler)) - /// .with_state(resolver); - /// ``` - pub async fn linked_state_handler( - State(resolver): State>, - Json(req): Json, - ) -> impl IntoResponse { - match resolver.resolve_request(&req).await { - Ok(val) => ( - StatusCode::OK, - Json(LinkResponse { - ok: Some(val), - error: None, - }), - ), - Err(err_val) => ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(LinkResponse { - ok: None, - error: Some(err_val), - }), - ), - } - } -} +#[cfg(not(target_arch = "wasm32"))] +mod services; -#[cfg(all(feature = "axum", not(target_arch = "wasm32")))] -pub use service::linked_state_handler; +#[cfg(not(target_arch = "wasm32"))] +pub use services::*; diff --git a/packages/yew-link/src/services.rs b/packages/yew-link/src/services.rs new file mode 100644 index 00000000000..5408207008d --- /dev/null +++ b/packages/yew-link/src/services.rs @@ -0,0 +1,87 @@ +#[cfg(all(feature = "axum", not(target_arch = "wasm32")))] +pub mod axum { + use std::sync::Arc; + + use axum::extract::State; + use axum::http::StatusCode; + use axum::response::IntoResponse; + use axum::Json; + + use crate::{LinkRequest, LinkResponse, Resolver}; + + /// Axum handler that resolves [`LinkRequest`]s. + /// + /// ```ignore + /// let resolver = Arc::new( + /// Resolver::new() + /// .register::(|id| async move { db::get_post(id).await }) + /// ); + /// + /// let app = axum::Router::new() + /// .route("/api/link", axum::routing::post(linked_state_handler)) + /// .with_state(resolver); + /// ``` + pub async fn linked_state_handler( + State(resolver): State>, + Json(req): Json, + ) -> impl IntoResponse { + match resolver.resolve_request(&req).await { + Ok(val) => ( + StatusCode::OK, + Json(LinkResponse { + ok: Some(val), + error: None, + }), + ), + Err(err_val) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LinkResponse { + ok: None, + error: Some(err_val), + }), + ), + } + } +} + +#[cfg(all(feature = "actix", not(target_arch = "wasm32")))] +pub mod service { + use actix_web::web::{Data, Json}; + use actix_web::HttpResponse; + + use crate::{LinkRequest, LinkResponse, Resolver}; + + /// Actix handler that resolves [`LinkRequest`]s. + /// + /// ```ignore + /// let resolver = Data::new( + /// Resolver::new() + /// .register::(|id| async move { db::get_post(id).await }) + /// ) + /// + /// HttpServer::new(move || { + /// App::new() + /// .route("/api/link", post().to(linked_state_handler)) + /// .data(resolver) + /// }) + /// .bind(("0.0.0.0", 8080))? + /// .run() + /// .await + /// ``` + pub async fn linked_state_handler( + resolver: Data, + Json(req): Json, + ) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(LinkResponse { + ok: Some(val), + error: None, + }), + + Err(err_val) => HttpResponse::UnprocessableEntity().json(LinkResponse { + ok: None, + error: Some(err_val), + }), + } + } +} diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx index ea544b0d34a..c8d8c1357c3 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -199,8 +199,8 @@ Multiple components requesting the same `(T, Input)` concurrently share a single #### Server setup -```rust ,ignore -use yew_link::{Resolver, linked_state_handler}; +```rust, ignore +use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( Resolver::new() @@ -212,9 +212,12 @@ let app = axum::Router::new() .with_state(resolver); ``` +There is multiple service providers, this example shows how to use `axum`, +but you can use actix in a similar way. + #### Component usage -```rust ,ignore +```rust, ignore use yew_link::{use_linked_state, LinkProvider}; #[component] From 11bee31fe15cd3920b2a53d0a575cc52a3e39ede Mon Sep 17 00:00:00 2001 From: stifskere Date: Sun, 5 Apr 2026 00:04:51 +0200 Subject: [PATCH 02/16] fix: #4113 errors from first commit --- Cargo.lock | 148 ++---------------- Cargo.toml | 1 - packages/yew-link/Cargo.toml | 2 +- packages/yew-link/src/services.rs | 10 +- .../advanced-topics/server-side-rendering.mdx | 11 +- 5 files changed, 25 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89e6e972503..b9efd21f9a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,6 @@ dependencies = [ "actix-codec", "actix-rt", "actix-service", - "actix-tls", "actix-utils", "base64", "bitflags 2.11.0", @@ -121,25 +120,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "actix-tls" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "impl-more", - "pin-project-lite", - "tokio", - "tokio-rustls 0.23.4", - "tokio-util", - "tracing", - "webpki-roots", -] - [[package]] name = "actix-utils" version = "3.0.1" @@ -163,7 +143,6 @@ dependencies = [ "actix-rt", "actix-server", "actix-service", - "actix-tls", "actix-utils", "actix-web-codegen", "bytes", @@ -1850,7 +1829,7 @@ dependencies = [ "hash32", "rustc_version", "serde", - "spin 0.9.8", + "spin", "stable_deref_trait", ] @@ -1968,10 +1947,10 @@ dependencies = [ "http 1.4.0", "hyper", "hyper-util", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", ] @@ -3022,7 +3001,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", + "rustls", "socket2 0.6.3", "thiserror 2.0.18", "tokio", @@ -3041,9 +3020,9 @@ dependencies = [ "getrandom 0.3.4", "lru-slab", "rand 0.9.2", - "ring 0.17.14", + "ring", "rustc-hash", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -3209,14 +3188,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3226,21 +3205,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.14" @@ -3251,7 +3215,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -3300,18 +3264,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.23.37" @@ -3359,7 +3311,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.37", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", @@ -3382,9 +3334,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", - "ring 0.17.14", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -3429,16 +3381,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "security-framework" version = "3.6.0" @@ -3647,12 +3589,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -4049,24 +3985,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls 0.20.9", - "tokio", - "webpki", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls", "tokio", ] @@ -4376,12 +4301,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -4692,16 +4611,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -4711,15 +4620,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" -dependencies = [ - "webpki", -] - [[package]] name = "website-test" version = "0.1.0" @@ -4741,22 +4641,6 @@ dependencies = [ "yew-router", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -4766,12 +4650,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 1564fd26659..4eca6101ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,4 +62,3 @@ thiserror = "2.0" bincode = { version = "2.0.0-rc.3", features = ["serde"] } reqwest = "0.13" axum = "0.8" -actix-web = { version = "4.13.0", features = ["rustls", "macros"] } diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml index 7c1ec6a7965..3fd2f597a20 100644 --- a/packages/yew-link/Cargo.toml +++ b/packages/yew-link/Cargo.toml @@ -20,7 +20,7 @@ wasm-bindgen-futures = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { workspace = true, optional = true } -actix-web = { workspace = true, optional = true } +actix-web = { version = "4.13", optional = true } [features] default = [] diff --git a/packages/yew-link/src/services.rs b/packages/yew-link/src/services.rs index 5408207008d..6611aa5a7aa 100644 --- a/packages/yew-link/src/services.rs +++ b/packages/yew-link/src/services.rs @@ -1,4 +1,4 @@ -#[cfg(all(feature = "axum", not(target_arch = "wasm32")))] +#[cfg(feature = "axum")] pub mod axum { use std::sync::Arc; @@ -44,8 +44,8 @@ pub mod axum { } } -#[cfg(all(feature = "actix", not(target_arch = "wasm32")))] -pub mod service { +#[cfg(feature = "actix")] +pub mod actix_web { use actix_web::web::{Data, Json}; use actix_web::HttpResponse; @@ -57,12 +57,12 @@ pub mod service { /// let resolver = Data::new( /// Resolver::new() /// .register::(|id| async move { db::get_post(id).await }) - /// ) + /// ); /// /// HttpServer::new(move || { /// App::new() /// .route("/api/link", post().to(linked_state_handler)) - /// .data(resolver) + /// .app_data(resolver) /// }) /// .bind(("0.0.0.0", 8080))? /// .run() diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx index c8d8c1357c3..3248ada9df0 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -148,7 +148,7 @@ The `yew-link` crate provides a higher-level abstraction that unifies all three 3. **Wrap** your app in ``. 4. **Call** `use_linked_state::(input)` in any component. -```rust ,ignore +```rust, ignore use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -171,7 +171,7 @@ The macro generates the `LinkedState` and (server-only) `LinkedStateResolve` tra If `resolve` can fail, declare `type Error`: -```rust ,ignore +```rust, ignore #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -200,7 +200,8 @@ Multiple components requesting the same `(T, Input)` concurrently share a single #### Server setup ```rust, ignore -use yew_link::{Resolver, axum::linked_state_handler}; +use yew_link::Resolver; +use axum::linked_state_handler; let resolver = Arc::new( Resolver::new() @@ -212,7 +213,7 @@ let app = axum::Router::new() .with_state(resolver); ``` -There is multiple service providers, this example shows how to use `axum`, +There are multiple service providers, this example shows how to use `axum`, but you can use actix in a similar way. #### Component usage @@ -298,7 +299,7 @@ until `rendered()` method is called. ## Example -```rust ,ignore +```rust, ignore use yew::prelude::*; use yew::Renderer; From 032699b03531d5e9e544333b5bcf196d0fb98a64 Mon Sep 17 00:00:00 2001 From: stifskere Date: Sun, 5 Apr 2026 00:29:24 +0200 Subject: [PATCH 03/16] fix: #4113 server-side-rendering documentation codeblock type separators --- website/docs/advanced-topics/server-side-rendering.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx index 3248ada9df0..f45aa48a911 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -148,7 +148,7 @@ The `yew-link` crate provides a higher-level abstraction that unifies all three 3. **Wrap** your app in ``. 4. **Call** `use_linked_state::(input)` in any component. -```rust, ignore +```rust ,ignore use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -171,7 +171,7 @@ The macro generates the `LinkedState` and (server-only) `LinkedStateResolve` tra If `resolve` can fail, declare `type Error`: -```rust, ignore +```rust ,ignore #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -199,7 +199,7 @@ Multiple components requesting the same `(T, Input)` concurrently share a single #### Server setup -```rust, ignore +```rust ,ignore use yew_link::Resolver; use axum::linked_state_handler; @@ -218,7 +218,7 @@ but you can use actix in a similar way. #### Component usage -```rust, ignore +```rust ,ignore use yew_link::{use_linked_state, LinkProvider}; #[component] @@ -299,7 +299,7 @@ until `rendered()` method is called. ## Example -```rust, ignore +```rust ,ignore use yew::prelude::*; use yew::Renderer; From 2623a6c668a09d37bc29557c5088ecb6474c39a5 Mon Sep 17 00:00:00 2001 From: stifskere Date: Sun, 5 Apr 2026 18:16:29 +0200 Subject: [PATCH 04/16] docs: #4113 add an example of actix yew_link resolver in the documentation --- .../advanced-topics/server-side-rendering.mdx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx index f45aa48a911..9e43a8c69cc 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -200,8 +200,7 @@ Multiple components requesting the same `(T, Input)` concurrently share a single #### Server setup ```rust ,ignore -use yew_link::Resolver; -use axum::linked_state_handler; +use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( Resolver::new() @@ -216,6 +215,24 @@ let app = axum::Router::new() There are multiple service providers, this example shows how to use `axum`, but you can use actix in a similar way. +```rust ,ignore +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::(db_pool.clone()) +); + +HttpServer::new(move || { + App::new() + .route("/api/link", post().to(linked_state_handler)) + .app_data(resolver) +}) + .bind(("0.0.0.0", 8080))? + .run() + .await +``` + #### Component usage ```rust ,ignore From 11d8ffb93e9f08dbad979a82da15e4afad8dcbf0 Mon Sep 17 00:00:00 2001 From: stifskere Date: Mon, 6 Apr 2026 17:21:04 +0200 Subject: [PATCH 05/16] chore: #4113 put services module under feature enabled by any of actix or axum --- Cargo.lock | 143 ++++-- examples/ssr_router/Cargo.toml | 56 --- examples/ssr_router/README.md | 15 - examples/ssr_router/index.html | 17 - .../ssr_router/src/bin/ssr_router_hydrate.rs | 18 - .../ssr_router/src/bin/ssr_router_server.rs | 196 -------- examples/ssr_router/src/lib.rs | 468 ------------------ examples/ssr_router/tests/e2e.rs | 199 -------- packages/yew-link/Cargo.toml | 5 +- packages/yew-link/src/lib.rs | 6 +- 10 files changed, 117 insertions(+), 1006 deletions(-) delete mode 100644 examples/ssr_router/Cargo.toml delete mode 100644 examples/ssr_router/README.md delete mode 100644 examples/ssr_router/index.html delete mode 100644 examples/ssr_router/src/bin/ssr_router_hydrate.rs delete mode 100644 examples/ssr_router/src/bin/ssr_router_server.rs delete mode 100644 examples/ssr_router/src/lib.rs delete mode 100644 examples/ssr_router/tests/e2e.rs diff --git a/Cargo.lock b/Cargo.lock index b9efd21f9a4..c79f7d0552b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,31 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" +dependencies = [ + "actix-macros", + "actix-rt", + "actix_derive", + "bitflags 2.11.0", + "bytes", + "crossbeam-channel", + "futures-core", + "futures-sink", + "futures-task", + "futures-util", + "log", + "once_cell", + "parking_lot", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", +] + [[package]] name = "actix-codec" version = "0.5.2" @@ -185,6 +210,45 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "actix_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix_ssr_router" +version = "0.1.0" +dependencies = [ + "actix", + "clap", + "env_logger", + "function_router", + "futures 0.3.32", + "getrandom 0.3.4", + "gloo", + "jemallocator", + "rand 0.9.2", + "serde", + "serde_json", + "ssr-e2e-harness", + "tokio", + "tracing-subscriber", + "tracing-web", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", + "yew-link", + "yew-router", +] + [[package]] name = "adler2" version = "2.0.1" @@ -413,6 +477,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum_ssr_router" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "env_logger", + "function_router", + "futures 0.3.32", + "getrandom 0.3.4", + "gloo", + "hyper", + "hyper-util", + "jemallocator", + "rand 0.9.2", + "serde", + "serde_json", + "ssr-e2e-harness", + "tokio", + "tower", + "tower-http", + "tracing-subscriber", + "tracing-web", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", + "yew-link", + "yew-router", +] + [[package]] name = "base64" version = "0.22.1" @@ -900,6 +996,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -3617,38 +3728,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "ssr_router" -version = "0.1.0" -dependencies = [ - "axum", - "clap", - "env_logger", - "function_router", - "futures 0.3.32", - "getrandom 0.3.4", - "gloo", - "hyper", - "hyper-util", - "jemallocator", - "rand 0.9.2", - "serde", - "serde_json", - "ssr-e2e-harness", - "tokio", - "tower", - "tower-http", - "tracing-subscriber", - "tracing-web", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test", - "web-sys", - "yew", - "yew-link", - "yew-router", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/examples/ssr_router/Cargo.toml b/examples/ssr_router/Cargo.toml deleted file mode 100644 index b250439cbb5..00000000000 --- a/examples/ssr_router/Cargo.toml +++ /dev/null @@ -1,56 +0,0 @@ -[package] -name = "ssr_router" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "ssr_router_hydrate" -required-features = ["hydration"] - -[[bin]] -name = "ssr_router_server" -required-features = ["ssr"] - -[dependencies] -yew = { path = "../../packages/yew" } -yew-link = { path = "../../packages/yew-link" } -yew-router = { path = "../../packages/yew-router" } -function_router = { path = "../function_router" } -rand = { workspace = true } -getrandom = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -futures = { workspace = true, features = ["std"] } -hyper-util = "0.1.20" - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen-futures.workspace = true -tracing-web.workspace = true -tracing-subscriber.workspace = true - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] } -axum = { workspace = true } -tower = { version = "0.5", features = ["make"] } -tower-http = { version = "0.6", features = ["fs", "cors"] } -env_logger = "0.11" -clap = { workspace = true } -hyper = { version = "1.4", features = ["server", "http1"] } - -[target.'cfg(unix)'.dependencies] -jemallocator = "0.5" - -[target.'cfg(target_arch = "wasm32")'.dev-dependencies] -wasm-bindgen-test.workspace = true -wasm-bindgen.workspace = true -web-sys = { workspace = true } -gloo = { workspace = true } -ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" } - -[dev-dependencies] -yew = { path = "../../packages/yew", features = ["hydration", "test"] } -yew-link = { path = "../../packages/yew-link", features = ["hydration"] } - -[features] -ssr = ["yew/ssr", "yew-link/ssr", "yew-link/axum"] -hydration = ["yew/hydration", "yew-link/hydration"] diff --git a/examples/ssr_router/README.md b/examples/ssr_router/README.md deleted file mode 100644 index 7a9ccebe364..00000000000 --- a/examples/ssr_router/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# SSR Router Example - -This example is the same as the function router example, but with -server-side rendering and hydration support. It reuses the same codebase -of the function router example. - -# How to run this example - -1. Build Hydration Bundle - -`trunk build` - -2. Run the server - -`cargo run --features=ssr --bin ssr_router_server -- --dir dist` diff --git a/examples/ssr_router/index.html b/examples/ssr_router/index.html deleted file mode 100644 index 98dfce4afeb..00000000000 --- a/examples/ssr_router/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - Yew SSR Router - - - - - - diff --git a/examples/ssr_router/src/bin/ssr_router_hydrate.rs b/examples/ssr_router/src/bin/ssr_router_hydrate.rs deleted file mode 100644 index 15fd7fbb162..00000000000 --- a/examples/ssr_router/src/bin/ssr_router_hydrate.rs +++ /dev/null @@ -1,18 +0,0 @@ -use ssr_router::{App, AppProps, LINK_ENDPOINT}; - -fn main() { - #[cfg(target_arch = "wasm32")] - { - let fmt_layer = tracing_subscriber::fmt::layer() - .with_ansi(false) // Only partially supported across browsers - .without_time() // std::time is not available in browsers - .with_writer(tracing_web::MakeWebConsoleWriter::new()) - .with_filter(tracing_subscriber::filter::LevelFilter::TRACE); - use tracing_subscriber::prelude::*; - tracing_subscriber::registry().with(fmt_layer).init(); - } - yew::Renderer::::with_props(AppProps { - endpoint: LINK_ENDPOINT.into(), - }) - .hydrate(); -} diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/ssr_router/src/bin/ssr_router_server.rs deleted file mode 100644 index e0915821437..00000000000 --- a/examples/ssr_router/src/bin/ssr_router_server.rs +++ /dev/null @@ -1,196 +0,0 @@ -use std::collections::HashMap; -use std::convert::Infallible; -use std::future::Future; -use std::net::SocketAddr; -use std::path::PathBuf; - -use axum::body::Body; -use axum::extract::{Query, Request, State}; -use axum::handler::HandlerWithoutStateExt; -use axum::http::Uri; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::Router; -use clap::Parser; -use function_router::{route_meta, Route}; -use futures::stream::{self, StreamExt}; -use hyper::body::Incoming; -use hyper_util::rt::TokioIo; -use hyper_util::server; -use ssr_router::{ - LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT, -}; -use tokio::net::TcpListener; -use tower::Service; -use tower_http::cors::CorsLayer; -use tower_http::services::ServeDir; -use yew::platform::Runtime; -use yew_link::axum::linked_state_handler; -use yew_link::{Resolver, ResolverProp}; -use yew_router::prelude::Routable; - -// We use jemalloc as it produces better performance. -#[cfg(unix)] -#[global_allocator] -static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; - -/// A basic example -#[derive(Parser, Debug)] -struct Opt { - /// the "dist" created by trunk directory to be served for hydration. - #[clap(short, long)] - dir: PathBuf, -} - -fn head_tags_for(path: &str) -> String { - let route = Route::recognize(path).unwrap_or(Route::NotFound); - let (title, description) = route_meta(&route); - format!( - "{title} | Yew SSR Router" - ) -} - -#[derive(Clone)] -struct AppState { - index_html_before: String, - index_html_after: String, - resolver: ResolverProp, -} - -async fn render( - url: Uri, - Query(queries): Query>, - State(state): State, -) -> impl IntoResponse { - let path = url.path().to_owned(); - - // Inject route-specific tags before , outside of Yew rendering. - let before = state - .index_html_before - .replace("", &format!("{}", head_tags_for(&path))); - let resolver = state.resolver.clone(); - - let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps { - url: path.into(), - queries, - resolver, - }); - - Body::from_stream( - stream::once(async move { before }) - .chain(renderer.render_stream()) - .chain(stream::once(async move { state.index_html_after })) - .map(Result::<_, Infallible>::Ok), - ) -} - -// An executor to process requests on the Yew runtime. -// -// By spawning requests on the Yew runtime, -// it processes request on the same thread as the rendering task. -// -// This increases performance in some environments (e.g.: in VM). -#[derive(Clone, Default)] -struct Executor { - inner: Runtime, -} - -impl hyper::rt::Executor for Executor -where - F: Future + Send + 'static, -{ - fn execute(&self, fut: F) { - self.inner.spawn_pinned(move || async move { - fut.await; - }); - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let exec = Executor::default(); - env_logger::init(); - let opts = Opt::parse(); - - let resolver_prop: ResolverProp = Resolver::new() - .register_linked::(()) - .register_linked::(()) - .register_linked::(()) - .into(); - let arc_resolver = resolver_prop.0.clone(); - - let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html")) - .await - .expect("failed to read index.html"); - - let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap(); - let mut index_html_before = index_html_before.to_owned(); - index_html_before.push_str(""); - let index_html_after = index_html_after.to_owned(); - - let app_state = AppState { - index_html_before, - index_html_after, - resolver: resolver_prop, - }; - - let app = Router::new() - .route( - LINK_ENDPOINT, - post(linked_state_handler).with_state(arc_resolver), - ) - .fallback_service( - ServeDir::new(opts.dir) - .append_index_html_on_directories(false) - .fallback(get(render).with_state(app_state).into_service()), - ) - .layer(CorsLayer::permissive()); - - let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); - println!("You can view the website at: http://localhost:8080/"); - - let listener = TcpListener::bind(addr).await?; - - // Continuously accept new connections. - loop { - // In this example we discard the remote address. See `fn serve_with_connect_info` for how - // to expose that. - let (socket, _remote_addr) = listener.accept().await.unwrap(); - - // We don't need to call `poll_ready` because `Router` is always ready. - let tower_service = app.clone(); - - let exec = exec.clone(); - // Spawn a task to handle the connection. That way we can handle multiple connections - // concurrently. - tokio::spawn(async move { - // Hyper has its own `AsyncRead` and `AsyncWrite` traits and doesn't use tokio. - // `TokioIo` converts between them. - let socket = TokioIo::new(socket); - - // Hyper also has its own `Service` trait and doesn't use tower. We can use - // `hyper::service::service_fn` to create a hyper `Service` that calls our app through - // `tower::Service::call`. - let hyper_service = hyper::service::service_fn(move |request: Request| { - // We have to clone `tower_service` because hyper's `Service` uses `&self` whereas - // tower's `Service` requires `&mut self`. - // - // We don't need to call `poll_ready` since `Router` is always ready. - tower_service.clone().call(request) - }); - - // `server::conn::auto::Builder` supports both http1 and http2. - // - // `TokioExecutor` tells hyper to use `tokio::spawn` to spawn tasks. - if let Err(err) = server::conn::auto::Builder::new(exec) - // `serve_connection_with_upgrades` is required for websockets. If you don't need - // that you can use `serve_connection` instead. - .serve_connection_with_upgrades(socket, hyper_service) - .await - { - eprintln!("failed to serve connection: {err:#}"); - } - }); - } -} diff --git a/examples/ssr_router/src/lib.rs b/examples/ssr_router/src/lib.rs deleted file mode 100644 index 3c20d42c984..00000000000 --- a/examples/ssr_router/src/lib.rs +++ /dev/null @@ -1,468 +0,0 @@ -use std::collections::HashMap; - -use function_router::content; -#[cfg(not(target_arch = "wasm32"))] -use function_router::Generated; -use serde::{Deserialize, Serialize}; -use yew::prelude::*; -use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp}; -use yew_router::prelude::*; - -pub const LINK_ENDPOINT: &str = "/api/link"; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct LinkedPost(pub content::Post); - -#[linked_state] -impl LinkedState for LinkedPost { - type Context = (); - type Input = u32; - - async fn resolve(_ctx: &(), seed: &u32) -> Self { - Self(content::Post::generate_from_seed(*seed)) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct LinkedAuthor(pub content::Author); - -#[linked_state] -impl LinkedState for LinkedAuthor { - type Context = (); - type Input = u32; - - async fn resolve(_ctx: &(), seed: &u32) -> Self { - Self(content::Author::generate_from_seed(*seed)) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct LinkedPostMeta(pub content::PostMeta); - -#[linked_state] -impl LinkedState for LinkedPostMeta { - type Context = (); - type Input = u32; - - async fn resolve(_ctx: &(), seed: &u32) -> Self { - Self(content::PostMeta::generate_from_seed(*seed)) - } -} - -#[derive(Properties, PartialEq, Clone)] -pub struct PostProps { - pub id: u32, -} - -#[component] -pub fn PostPage(props: &PostProps) -> HtmlResult { - let post = use_linked_state::(props.id)?.data(); - Ok(render_post(&post.0)) -} - -fn render_post(post: &content::Post) -> Html { - use content::PostPart; - - let render_quote = |quote: &content::Quote| { - html! { -
-
-

- The author's profile -

-
-
-
- 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! { -
- Section image -
-
-

{ §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! { - <> -
- Hero background -
-
-

{ &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! { -
-
-
-
-

{ &author.name }

-
-
-
-
-
-

{ "Interests" }

-
- for tag in &author.keywords { - { tag } - } -
-
-
-
-
- Profile picture -
-
-
-
-
-

{ "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! { -
-
-
- This post's image -
-
-
- 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! { -
-
-
-
-
- Author's profile picture -
-
-
-

{ &author.name }

-

- { "I like " } - { author.keywords.join(", ") } -

-
-
-
-
- classes={classes!("card-footer-item")} to={function_router::Route::Author { id: author.seed }}> - { "Profile" } - > -
-
- }) -} - -const ITEMS_PER_PAGE: u32 = 10; -const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE; - -#[component] -fn LinkedPostList() -> Html { - use function_router::components::pagination::{PageQuery, Pagination}; - - let location = use_location().unwrap(); - let current_page = location.query::().map(|it| it.page).unwrap_or(1); - - let start_seed = (current_page - 1) * ITEMS_PER_PAGE; - let half = ITEMS_PER_PAGE / 2; - - html! { -
-

{ "Posts" }

-

{ "All of our quality writing in one place" }

-
-
-
    - for offset in 0..half { -
  • -
    {"Loading..."}
}}> - - - - } - -
-
-
    - for offset in half..ITEMS_PER_PAGE { -
  • -
    {"Loading..."}
}}> - - - - } - -
- - - - } -} - -#[component] -fn LinkedAuthorList() -> Html { - let seeds = use_state(|| { - use rand::{distr, Rng}; - rand::rng() - .sample_iter(distr::StandardUniform) - .take(2) - .collect::>() - }); - - let on_complete = { - let seeds = seeds.clone(); - Callback::from(move |_| { - use rand::{distr, Rng}; - seeds.set( - rand::rng() - .sample_iter(distr::StandardUniform) - .take(2) - .collect(), - ); - }) - }; - - html! { -
-
-
-
-

{ "Authors" }

-

- { "Meet the definitely real people behind your favourite Yew content" } -

-
-
-
-

- { "It wouldn't be fair " } - { "(or possible :P)" } - {" to list each and every author in alphabetical order."} -
- { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" } -

-
-
- for seed in seeds.iter().copied() { -
-
-
{"Loading..."}
}}> - - -
-
- } -
- -
- - } -} - -fn switch(routes: function_router::Route) -> Html { - use function_router::Route; - - match routes { - Route::Post { id } => html! { - {"Loading post..."}

}}> - -
- }, - Route::Author { id } => html! { - {"Loading author..."}

}}> - -
- }, - Route::Posts => html! { }, - Route::Authors => html! { }, - Route::Home => html! { }, - Route::NotFound => html! { }, - } -} - -#[derive(Properties, PartialEq)] -pub struct AppProps { - pub endpoint: AttrValue, -} - -#[component] -pub fn App(props: &AppProps) -> Html { - html! { - - - -
- render={switch} /> -
- -
-
- } -} - -#[derive(Properties, PartialEq, Debug)] -pub struct ServerAppProps { - pub url: AttrValue, - pub queries: HashMap, - pub resolver: ResolverProp, -} - -#[component] -pub fn ServerApp(props: &ServerAppProps) -> Html { - use yew_router::history::{AnyHistory, History, MemoryHistory}; - - let history = AnyHistory::from(MemoryHistory::new()); - history - .push_with_query(&*props.url, &props.queries) - .unwrap(); - - html! { - - - -
- render={switch} /> -
- -
-
- } -} diff --git a/examples/ssr_router/tests/e2e.rs b/examples/ssr_router/tests/e2e.rs deleted file mode 100644 index 39887013cf1..00000000000 --- a/examples/ssr_router/tests/e2e.rs +++ /dev/null @@ -1,199 +0,0 @@ -#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] - -use gloo::utils::document; -use ssr_e2e_harness::{ - clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, - wait_for, -}; -use ssr_router::{App, AppProps, LINK_ENDPOINT}; -use wasm_bindgen::JsCast; -use wasm_bindgen_test::*; -use yew::Renderer; - -wasm_bindgen_test_configure!(run_in_browser); - -const SERVER_BASE: &str = "http://127.0.0.1:8080"; - -fn endpoint() -> String { - format!("{SERVER_BASE}{LINK_ENDPOINT}") -} - -fn make_renderer() -> Renderer { - Renderer::::with_root_and_props( - output_element(), - AppProps { - endpoint: endpoint().into(), - }, - ) -} - -fn get_title_text() -> Option { - document() - .query_selector("h1.title") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) -} - -fn post_body_text() -> String { - output_element() - .query_selector(".section.container") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) - .unwrap_or_default() -} - -fn extract_text_from_html(html: &str, selector: &str) -> Option { - let container = document().create_element("div").unwrap(); - container.set_inner_html(html); - container - .query_selector(selector) - .ok() - .flatten() - .and_then(|el| el.text_content()) -} - -#[wasm_bindgen_test] -async fn ssr_hydration_and_client_navigation() { - // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link -- - - let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await; - let ssr_title = extract_text_from_html(&ssr_html, "h1.title") - .expect("SSR HTML for /posts/0 should contain h1.title"); - let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); - - clear_resource_timings(); - - output_element().set_inner_html(&ssr_html); - ssr_e2e_harness::push_route("/posts/0"); - let app = make_renderer().hydrate(); - - wait_for( - || { - let html = output_element().inner_html(); - html.contains("

") && !html.contains("Loading post...") - }, - 5000, - "post page content after SSR hydration", - ) - .await; - - let link_fetches = resource_request_count(LINK_ENDPOINT); - let title = get_title_text(); - - assert_eq!( - link_fetches, 0, - "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}" - ); - let title = title.expect("h1.title should be present on the SSR post page"); - assert!(!title.is_empty(), "SSR post title should not be empty"); - - // -- Part 2: Navigate to /posts within the same app, then to /posts/0 -- - - yew::scheduler::flush().await; - - clear_resource_timings(); - - let posts_link: web_sys::HtmlElement = output_element() - .query_selector("a.navbar-item[href='/posts']") - .unwrap() - .expect("Posts navbar link should exist") - .dyn_into() - .unwrap(); - posts_link.click(); - yew::scheduler::flush().await; - - wait_for( - || { - document() - .query_selector("a.title.is-block") - .ok() - .flatten() - .is_some() - && get_title_text().as_deref() == Some("Posts") - }, - 15000, - "posts list after client-side navigation to /posts", - ) - .await; - - clear_resource_timings(); - - wait_for( - || { - output_element() - .query_selector("a.title.is-block[href='/posts/0']") - .ok() - .flatten() - .is_some() - }, - 15000, - "post 0 card link on posts list", - ) - .await; - - let post_link: web_sys::HtmlElement = output_element() - .query_selector("a.title.is-block[href='/posts/0']") - .unwrap() - .unwrap() - .dyn_into() - .unwrap(); - post_link.click(); - yew::scheduler::flush().await; - - wait_for( - || { - document() - .query_selector("h2.subtitle") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) - .is_some_and(|text| text.starts_with("by ")) - }, - 15000, - "post page content after client-side navigation to /posts/0", - ) - .await; - - // -- Part 3: Verify fetch happened and content matches SSR -- - - let nav_link_fetches = resource_request_count(LINK_ENDPOINT); - let nav_title = get_title_text(); - let nav_body = post_body_text(); - - assert!( - nav_link_fetches >= 1, - "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \ - got {nav_link_fetches}" - ); - - let nav_title = nav_title.expect("h1.title should be present after client-side navigation"); - assert_eq!( - ssr_title, nav_title, - "post title should match between SSR and client-side navigation" - ); - assert_eq!( - ssr_body, nav_body, - "post body should match between SSR and client-side navigation" - ); - - app.destroy(); - yew::scheduler::flush().await; -} - -#[wasm_bindgen_test] -async fn hydrate_home() { - setup_ssr_page(SERVER_BASE, "/").await; - let app = make_renderer().hydrate(); - - wait_for( - || output_element().inner_html().contains("Welcome"), - 5000, - "home page content after hydration", - ) - .await; - - app.destroy(); - yew::scheduler::flush().await; -} diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml index 3fd2f597a20..2b7efebc6c0 100644 --- a/packages/yew-link/Cargo.toml +++ b/packages/yew-link/Cargo.toml @@ -26,8 +26,9 @@ actix-web = { version = "4.13", optional = true } default = [] ssr = ["yew/ssr"] hydration = ["yew/hydration"] -axum = ["dep:axum"] -actix = ["dep:actix-web"] +axum = ["dep:axum", "services"] +actix = ["dep:actix-web", "services"] +services = [] [lints] workspace = true diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs index 4e09dd807b7..855fe3d6763 100644 --- a/packages/yew-link/src/lib.rs +++ b/packages/yew-link/src/lib.rs @@ -290,7 +290,7 @@ impl Eq for CacheKey {} fn eq_inputs(a: &dyn Any, b: &dyn Any) -> bool { a.downcast_ref::() .zip(b.downcast_ref::()) - .map_or(false, |(a, b)| a == b) + .is_some_and(|(a, b)| a == b) } #[cfg(target_arch = "wasm32")] @@ -679,8 +679,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult Date: Mon, 6 Apr 2026 19:51:33 +0200 Subject: [PATCH 06/16] chore: #4113 add a test for actix_ssr_router using yew_link --- Cargo.lock | 54 +- examples/actix_ssr_router/Cargo.toml | 53 ++ examples/actix_ssr_router/README.md | 14 + examples/actix_ssr_router/index.html | 17 + .../src/bin/ssr_router_hydrate.rs | 18 + .../src/bin/ssr_router_server.rs | 112 +++++ examples/actix_ssr_router/src/lib.rs | 468 ++++++++++++++++++ examples/actix_ssr_router/tests/e2e.rs | 199 ++++++++ examples/axum_ssr_router/Cargo.toml | 56 +++ examples/axum_ssr_router/README.md | 15 + examples/axum_ssr_router/index.html | 17 + .../src/bin/ssr_router_hydrate.rs | 18 + .../src/bin/ssr_router_server.rs | 196 ++++++++ examples/axum_ssr_router/src/lib.rs | 468 ++++++++++++++++++ examples/axum_ssr_router/tests/e2e.rs | 199 ++++++++ 15 files changed, 1852 insertions(+), 52 deletions(-) create mode 100644 examples/actix_ssr_router/Cargo.toml create mode 100644 examples/actix_ssr_router/README.md create mode 100644 examples/actix_ssr_router/index.html create mode 100644 examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs create mode 100644 examples/actix_ssr_router/src/bin/ssr_router_server.rs create mode 100644 examples/actix_ssr_router/src/lib.rs create mode 100644 examples/actix_ssr_router/tests/e2e.rs create mode 100644 examples/axum_ssr_router/Cargo.toml create mode 100644 examples/axum_ssr_router/README.md create mode 100644 examples/axum_ssr_router/index.html create mode 100644 examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs create mode 100644 examples/axum_ssr_router/src/bin/ssr_router_server.rs create mode 100644 examples/axum_ssr_router/src/lib.rs create mode 100644 examples/axum_ssr_router/tests/e2e.rs diff --git a/Cargo.lock b/Cargo.lock index c79f7d0552b..2b1f58c27d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,31 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "actix" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" -dependencies = [ - "actix-macros", - "actix-rt", - "actix_derive", - "bitflags 2.11.0", - "bytes", - "crossbeam-channel", - "futures-core", - "futures-sink", - "futures-task", - "futures-util", - "log", - "once_cell", - "parking_lot", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", -] - [[package]] name = "actix-codec" version = "0.5.2" @@ -210,22 +185,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "actix_derive" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "actix_ssr_router" version = "0.1.0" dependencies = [ - "actix", + "actix-web", + "bytes", "clap", "env_logger", "function_router", @@ -996,21 +961,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "crypto-common" version = "0.1.7" diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml new file mode 100644 index 00000000000..7b4759f4640 --- /dev/null +++ b/examples/actix_ssr_router/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "actix_ssr_router" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "ssr_router_hydrate" +required-features = ["hydration"] + +[[bin]] +name = "ssr_router_server" +required-features = ["ssr"] + +[dependencies] +yew = { path = "../../packages/yew" } +yew-link = { path = "../../packages/yew-link" } +yew-router = { path = "../../packages/yew-router" } +function_router = { path = "../function_router" } +rand = { workspace = true } +getrandom = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +futures = { workspace = true, features = ["std"] } +bytes = "1.11.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures.workspace = true +tracing-web.workspace = true +tracing-subscriber.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] } +env_logger = "0.11" +clap = { workspace = true } +actix-web = "4.13.0" + +[target.'cfg(unix)'.dependencies] +jemallocator = "0.5" + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test.workspace = true +wasm-bindgen.workspace = true +web-sys = { workspace = true } +gloo = { workspace = true } +ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" } + +[dev-dependencies] +yew = { path = "../../packages/yew", features = ["hydration", "test"] } +yew-link = { path = "../../packages/yew-link", features = ["hydration"] } + +[features] +ssr = ["yew/ssr", "yew-link/ssr", "yew-link/actix"] +hydration = ["yew/hydration", "yew-link/hydration"] diff --git a/examples/actix_ssr_router/README.md b/examples/actix_ssr_router/README.md new file mode 100644 index 00000000000..0621f345acb --- /dev/null +++ b/examples/actix_ssr_router/README.md @@ -0,0 +1,14 @@ +# SSR Router Example + +This example is the same as the `axum_ssr_router`, except the +server side is served with `actix_web` instead of axum. + +# How to run this example + +1. Build Hydration Bundle + +`trunk build` + +2. Run the server + +`cargo run --features=ssr --bin ssr_router_server -- --dir dist` diff --git a/examples/actix_ssr_router/index.html b/examples/actix_ssr_router/index.html new file mode 100644 index 00000000000..98dfce4afeb --- /dev/null +++ b/examples/actix_ssr_router/index.html @@ -0,0 +1,17 @@ + + + + + + + + Yew SSR Router + + + + + + diff --git a/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs b/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs new file mode 100644 index 00000000000..38761324508 --- /dev/null +++ b/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs @@ -0,0 +1,18 @@ +use actix_ssr_router::{App, AppProps, LINK_ENDPOINT}; + +fn main() { + #[cfg(target_arch = "wasm32")] + { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) // Only partially supported across browsers + .without_time() // std::time is not available in browsers + .with_writer(tracing_web::MakeWebConsoleWriter::new()) + .with_filter(tracing_subscriber::filter::LevelFilter::TRACE); + use tracing_subscriber::prelude::*; + tracing_subscriber::registry().with(fmt_layer).init(); + } + yew::Renderer::::with_props(AppProps { + endpoint: LINK_ENDPOINT.into(), + }) + .hydrate(); +} diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs new file mode 100644 index 00000000000..e18f7db05a2 --- /dev/null +++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; +use std::io::Result as IoResult; +use std::path::PathBuf; + +use actix_ssr_router::{ + LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT, +}; +use actix_web::http::Uri; +use actix_web::web::{get, post, Data, Query}; +use actix_web::{App, Error, HttpResponse, HttpServer}; +use bytes::Bytes; +use clap::Parser; +use function_router::{route_meta, Route}; +use futures::stream::{self, StreamExt}; +use yew_link::actix_web::linked_state_handler; +use yew_link::{Resolver, ResolverProp}; +use yew_router::prelude::Routable; + +// We use jemalloc as it produces better performance. +#[cfg(unix)] +#[global_allocator] +static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; + +/// A basic example +#[derive(Parser, Debug)] +struct Opt { + /// the "dist" created by trunk directory to be served for hydration. + #[clap(short, long)] + dir: PathBuf, +} + +fn head_tags_for(path: &str) -> String { + let route = Route::recognize(path).unwrap_or(Route::NotFound); + let (title, description) = route_meta(&route); + format!( + "{title} | Yew SSR Router" + ) +} + +#[derive(Clone)] +struct AppState { + index_html_before: String, + index_html_after: String, + resolver: ResolverProp, +} + +async fn render( + url: Uri, + Query(queries): Query>, + state: Data, +) -> HttpResponse { + let state = state.into_inner(); + + let path = url.path().to_owned(); + + // Inject route-specific tags before , outside of Yew rendering. + let before = state + .index_html_before + .replace("", &format!("{}", head_tags_for(&path))); + let resolver = state.resolver.clone(); + + let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps { + url: path.into(), + queries, + resolver, + }); + + HttpResponse::Ok().streaming( + stream::once(async move { Bytes::from(before) }) + .chain(renderer.render_stream().map(Bytes::from)) + .chain(stream::once(async move { + Bytes::from(state.index_html_after.clone()) + })) + .map(Ok::), + ) +} + +#[actix_web::main] +async fn main() -> IoResult<()> { + env_logger::init(); + let opts = Opt::parse(); + + let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html")) + .await + .expect("failed to read index.html"); + + let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap(); + let mut index_html_before = index_html_before.to_owned(); + index_html_before.push_str(""); + let index_html_after = index_html_after.to_owned(); + + let app_state = Data::new(AppState { + index_html_before, + index_html_after, + resolver: Resolver::new() + .register_linked::(()) + .register_linked::(()) + .register_linked::(()) + .into(), + }); + + HttpServer::new(move || { + App::new() + .app_data(app_state.clone()) + .route(LINK_ENDPOINT, post().to(linked_state_handler)) + .default_service(get().to(render)) + }) + .bind(("0.0.0.0", 8080))? + .run() + .await +} diff --git a/examples/actix_ssr_router/src/lib.rs b/examples/actix_ssr_router/src/lib.rs new file mode 100644 index 00000000000..3c20d42c984 --- /dev/null +++ b/examples/actix_ssr_router/src/lib.rs @@ -0,0 +1,468 @@ +use std::collections::HashMap; + +use function_router::content; +#[cfg(not(target_arch = "wasm32"))] +use function_router::Generated; +use serde::{Deserialize, Serialize}; +use yew::prelude::*; +use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp}; +use yew_router::prelude::*; + +pub const LINK_ENDPOINT: &str = "/api/link"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPost(pub content::Post); + +#[linked_state] +impl LinkedState for LinkedPost { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Post::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedAuthor(pub content::Author); + +#[linked_state] +impl LinkedState for LinkedAuthor { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Author::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPostMeta(pub content::PostMeta); + +#[linked_state] +impl LinkedState for LinkedPostMeta { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::PostMeta::generate_from_seed(*seed)) + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct PostProps { + pub id: u32, +} + +#[component] +pub fn PostPage(props: &PostProps) -> HtmlResult { + let post = use_linked_state::(props.id)?.data(); + Ok(render_post(&post.0)) +} + +fn render_post(post: &content::Post) -> Html { + use content::PostPart; + + let render_quote = |quote: &content::Quote| { + html! { +
+
+

+ The author's profile +

+
+
+
+ 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! { +
+ Section image +
+
+

{ §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! { + <> +
+ Hero background +
+
+

{ &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! { +
+
+
+
+

{ &author.name }

+
+
+
+
+
+

{ "Interests" }

+
+ for tag in &author.keywords { + { tag } + } +
+
+
+
+
+ Profile picture +
+
+
+
+
+

{ "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! { +
+
+
+ This post's image +
+
+
+ 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! { +
+
+
+
+
+ Author's profile picture +
+
+
+

{ &author.name }

+

+ { "I like " } + { author.keywords.join(", ") } +

+
+
+
+
+ classes={classes!("card-footer-item")} to={function_router::Route::Author { id: author.seed }}> + { "Profile" } + > +
+
+ }) +} + +const ITEMS_PER_PAGE: u32 = 10; +const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE; + +#[component] +fn LinkedPostList() -> Html { + use function_router::components::pagination::{PageQuery, Pagination}; + + let location = use_location().unwrap(); + let current_page = location.query::().map(|it| it.page).unwrap_or(1); + + let start_seed = (current_page - 1) * ITEMS_PER_PAGE; + let half = ITEMS_PER_PAGE / 2; + + html! { +
+

{ "Posts" }

+

{ "All of our quality writing in one place" }

+
+
+
    + for offset in 0..half { +
  • +
    {"Loading..."}
}}> + + + + } + +
+
+
    + for offset in half..ITEMS_PER_PAGE { +
  • +
    {"Loading..."}
}}> + + + + } + +
+ + + + } +} + +#[component] +fn LinkedAuthorList() -> Html { + let seeds = use_state(|| { + use rand::{distr, Rng}; + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect::>() + }); + + let on_complete = { + let seeds = seeds.clone(); + Callback::from(move |_| { + use rand::{distr, Rng}; + seeds.set( + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect(), + ); + }) + }; + + html! { +
+
+
+
+

{ "Authors" }

+

+ { "Meet the definitely real people behind your favourite Yew content" } +

+
+
+
+

+ { "It wouldn't be fair " } + { "(or possible :P)" } + {" to list each and every author in alphabetical order."} +
+ { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" } +

+
+
+ for seed in seeds.iter().copied() { +
+
+
{"Loading..."}
}}> + + +
+
+ } +
+ +
+ + } +} + +fn switch(routes: function_router::Route) -> Html { + use function_router::Route; + + match routes { + Route::Post { id } => html! { + {"Loading post..."}

}}> + +
+ }, + Route::Author { id } => html! { + {"Loading author..."}

}}> + +
+ }, + Route::Posts => html! { }, + Route::Authors => html! { }, + Route::Home => html! { }, + Route::NotFound => html! { }, + } +} + +#[derive(Properties, PartialEq)] +pub struct AppProps { + pub endpoint: AttrValue, +} + +#[component] +pub fn App(props: &AppProps) -> Html { + html! { + + + +
+ render={switch} /> +
+ +
+
+ } +} + +#[derive(Properties, PartialEq, Debug)] +pub struct ServerAppProps { + pub url: AttrValue, + pub queries: HashMap, + pub resolver: ResolverProp, +} + +#[component] +pub fn ServerApp(props: &ServerAppProps) -> Html { + use yew_router::history::{AnyHistory, History, MemoryHistory}; + + let history = AnyHistory::from(MemoryHistory::new()); + history + .push_with_query(&*props.url, &props.queries) + .unwrap(); + + html! { + + + +
+ render={switch} /> +
+ +
+
+ } +} diff --git a/examples/actix_ssr_router/tests/e2e.rs b/examples/actix_ssr_router/tests/e2e.rs new file mode 100644 index 00000000000..39887013cf1 --- /dev/null +++ b/examples/actix_ssr_router/tests/e2e.rs @@ -0,0 +1,199 @@ +#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + +use gloo::utils::document; +use ssr_e2e_harness::{ + clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, + wait_for, +}; +use ssr_router::{App, AppProps, LINK_ENDPOINT}; +use wasm_bindgen::JsCast; +use wasm_bindgen_test::*; +use yew::Renderer; + +wasm_bindgen_test_configure!(run_in_browser); + +const SERVER_BASE: &str = "http://127.0.0.1:8080"; + +fn endpoint() -> String { + format!("{SERVER_BASE}{LINK_ENDPOINT}") +} + +fn make_renderer() -> Renderer { + Renderer::::with_root_and_props( + output_element(), + AppProps { + endpoint: endpoint().into(), + }, + ) +} + +fn get_title_text() -> Option { + document() + .query_selector("h1.title") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) +} + +fn post_body_text() -> String { + output_element() + .query_selector(".section.container") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) + .unwrap_or_default() +} + +fn extract_text_from_html(html: &str, selector: &str) -> Option { + let container = document().create_element("div").unwrap(); + container.set_inner_html(html); + container + .query_selector(selector) + .ok() + .flatten() + .and_then(|el| el.text_content()) +} + +#[wasm_bindgen_test] +async fn ssr_hydration_and_client_navigation() { + // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link -- + + let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await; + let ssr_title = extract_text_from_html(&ssr_html, "h1.title") + .expect("SSR HTML for /posts/0 should contain h1.title"); + let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); + + clear_resource_timings(); + + output_element().set_inner_html(&ssr_html); + ssr_e2e_harness::push_route("/posts/0"); + let app = make_renderer().hydrate(); + + wait_for( + || { + let html = output_element().inner_html(); + html.contains("

") && !html.contains("Loading post...") + }, + 5000, + "post page content after SSR hydration", + ) + .await; + + let link_fetches = resource_request_count(LINK_ENDPOINT); + let title = get_title_text(); + + assert_eq!( + link_fetches, 0, + "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}" + ); + let title = title.expect("h1.title should be present on the SSR post page"); + assert!(!title.is_empty(), "SSR post title should not be empty"); + + // -- Part 2: Navigate to /posts within the same app, then to /posts/0 -- + + yew::scheduler::flush().await; + + clear_resource_timings(); + + let posts_link: web_sys::HtmlElement = output_element() + .query_selector("a.navbar-item[href='/posts']") + .unwrap() + .expect("Posts navbar link should exist") + .dyn_into() + .unwrap(); + posts_link.click(); + yew::scheduler::flush().await; + + wait_for( + || { + document() + .query_selector("a.title.is-block") + .ok() + .flatten() + .is_some() + && get_title_text().as_deref() == Some("Posts") + }, + 15000, + "posts list after client-side navigation to /posts", + ) + .await; + + clear_resource_timings(); + + wait_for( + || { + output_element() + .query_selector("a.title.is-block[href='/posts/0']") + .ok() + .flatten() + .is_some() + }, + 15000, + "post 0 card link on posts list", + ) + .await; + + let post_link: web_sys::HtmlElement = output_element() + .query_selector("a.title.is-block[href='/posts/0']") + .unwrap() + .unwrap() + .dyn_into() + .unwrap(); + post_link.click(); + yew::scheduler::flush().await; + + wait_for( + || { + document() + .query_selector("h2.subtitle") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) + .is_some_and(|text| text.starts_with("by ")) + }, + 15000, + "post page content after client-side navigation to /posts/0", + ) + .await; + + // -- Part 3: Verify fetch happened and content matches SSR -- + + let nav_link_fetches = resource_request_count(LINK_ENDPOINT); + let nav_title = get_title_text(); + let nav_body = post_body_text(); + + assert!( + nav_link_fetches >= 1, + "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \ + got {nav_link_fetches}" + ); + + let nav_title = nav_title.expect("h1.title should be present after client-side navigation"); + assert_eq!( + ssr_title, nav_title, + "post title should match between SSR and client-side navigation" + ); + assert_eq!( + ssr_body, nav_body, + "post body should match between SSR and client-side navigation" + ); + + app.destroy(); + yew::scheduler::flush().await; +} + +#[wasm_bindgen_test] +async fn hydrate_home() { + setup_ssr_page(SERVER_BASE, "/").await; + let app = make_renderer().hydrate(); + + wait_for( + || output_element().inner_html().contains("Welcome"), + 5000, + "home page content after hydration", + ) + .await; + + app.destroy(); + yew::scheduler::flush().await; +} diff --git a/examples/axum_ssr_router/Cargo.toml b/examples/axum_ssr_router/Cargo.toml new file mode 100644 index 00000000000..1fa92161f1a --- /dev/null +++ b/examples/axum_ssr_router/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "axum_ssr_router" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "ssr_router_hydrate" +required-features = ["hydration"] + +[[bin]] +name = "ssr_router_server" +required-features = ["ssr"] + +[dependencies] +yew = { path = "../../packages/yew" } +yew-link = { path = "../../packages/yew-link" } +yew-router = { path = "../../packages/yew-router" } +function_router = { path = "../function_router" } +rand = { workspace = true } +getrandom = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +futures = { workspace = true, features = ["std"] } +hyper-util = "0.1.20" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures.workspace = true +tracing-web.workspace = true +tracing-subscriber.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] } +axum = { workspace = true } +tower = { version = "0.5", features = ["make"] } +tower-http = { version = "0.6", features = ["fs", "cors"] } +env_logger = "0.11" +clap = { workspace = true } +hyper = { version = "1.4", features = ["server", "http1"] } + +[target.'cfg(unix)'.dependencies] +jemallocator = "0.5" + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test.workspace = true +wasm-bindgen.workspace = true +web-sys = { workspace = true } +gloo = { workspace = true } +ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" } + +[dev-dependencies] +yew = { path = "../../packages/yew", features = ["hydration", "test"] } +yew-link = { path = "../../packages/yew-link", features = ["hydration"] } + +[features] +ssr = ["yew/ssr", "yew-link/ssr", "yew-link/axum"] +hydration = ["yew/hydration", "yew-link/hydration"] diff --git a/examples/axum_ssr_router/README.md b/examples/axum_ssr_router/README.md new file mode 100644 index 00000000000..7a9ccebe364 --- /dev/null +++ b/examples/axum_ssr_router/README.md @@ -0,0 +1,15 @@ +# SSR Router Example + +This example is the same as the function router example, but with +server-side rendering and hydration support. It reuses the same codebase +of the function router example. + +# How to run this example + +1. Build Hydration Bundle + +`trunk build` + +2. Run the server + +`cargo run --features=ssr --bin ssr_router_server -- --dir dist` diff --git a/examples/axum_ssr_router/index.html b/examples/axum_ssr_router/index.html new file mode 100644 index 00000000000..98dfce4afeb --- /dev/null +++ b/examples/axum_ssr_router/index.html @@ -0,0 +1,17 @@ + + + + + + + + Yew SSR Router + + + + + + diff --git a/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs new file mode 100644 index 00000000000..571339c8071 --- /dev/null +++ b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs @@ -0,0 +1,18 @@ +use axum_ssr_router::{App, AppProps, LINK_ENDPOINT}; + +fn main() { + #[cfg(target_arch = "wasm32")] + { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) // Only partially supported across browsers + .without_time() // std::time is not available in browsers + .with_writer(tracing_web::MakeWebConsoleWriter::new()) + .with_filter(tracing_subscriber::filter::LevelFilter::TRACE); + use tracing_subscriber::prelude::*; + tracing_subscriber::registry().with(fmt_layer).init(); + } + yew::Renderer::::with_props(AppProps { + endpoint: LINK_ENDPOINT.into(), + }) + .hydrate(); +} diff --git a/examples/axum_ssr_router/src/bin/ssr_router_server.rs b/examples/axum_ssr_router/src/bin/ssr_router_server.rs new file mode 100644 index 00000000000..26e3c4e09ac --- /dev/null +++ b/examples/axum_ssr_router/src/bin/ssr_router_server.rs @@ -0,0 +1,196 @@ +use std::collections::HashMap; +use std::convert::Infallible; +use std::future::Future; +use std::net::SocketAddr; +use std::path::PathBuf; + +use axum::body::Body; +use axum::extract::{Query, Request, State}; +use axum::handler::HandlerWithoutStateExt; +use axum::http::Uri; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::Router; +use axum_ssr_router::{ + LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT, +}; +use clap::Parser; +use function_router::{route_meta, Route}; +use futures::stream::{self, StreamExt}; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use hyper_util::server; +use tokio::net::TcpListener; +use tower::Service; +use tower_http::cors::CorsLayer; +use tower_http::services::ServeDir; +use yew::platform::Runtime; +use yew_link::axum::linked_state_handler; +use yew_link::{Resolver, ResolverProp}; +use yew_router::prelude::Routable; + +// We use jemalloc as it produces better performance. +#[cfg(unix)] +#[global_allocator] +static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; + +/// A basic example +#[derive(Parser, Debug)] +struct Opt { + /// the "dist" created by trunk directory to be served for hydration. + #[clap(short, long)] + dir: PathBuf, +} + +fn head_tags_for(path: &str) -> String { + let route = Route::recognize(path).unwrap_or(Route::NotFound); + let (title, description) = route_meta(&route); + format!( + "{title} | Yew SSR Router" + ) +} + +#[derive(Clone)] +struct AppState { + index_html_before: String, + index_html_after: String, + resolver: ResolverProp, +} + +async fn render( + url: Uri, + Query(queries): Query>, + State(state): State, +) -> impl IntoResponse { + let path = url.path().to_owned(); + + // Inject route-specific tags before , outside of Yew rendering. + let before = state + .index_html_before + .replace("", &format!("{}", head_tags_for(&path))); + let resolver = state.resolver.clone(); + + let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps { + url: path.into(), + queries, + resolver, + }); + + Body::from_stream( + stream::once(async move { before }) + .chain(renderer.render_stream()) + .chain(stream::once(async move { state.index_html_after })) + .map(Result::<_, Infallible>::Ok), + ) +} + +// An executor to process requests on the Yew runtime. +// +// By spawning requests on the Yew runtime, +// it processes request on the same thread as the rendering task. +// +// This increases performance in some environments (e.g.: in VM). +#[derive(Clone, Default)] +struct Executor { + inner: Runtime, +} + +impl hyper::rt::Executor for Executor +where + F: Future + Send + 'static, +{ + fn execute(&self, fut: F) { + self.inner.spawn_pinned(move || async move { + fut.await; + }); + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let exec = Executor::default(); + env_logger::init(); + let opts = Opt::parse(); + + let resolver_prop: ResolverProp = Resolver::new() + .register_linked::(()) + .register_linked::(()) + .register_linked::(()) + .into(); + let arc_resolver = resolver_prop.0.clone(); + + let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html")) + .await + .expect("failed to read index.html"); + + let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap(); + let mut index_html_before = index_html_before.to_owned(); + index_html_before.push_str(""); + let index_html_after = index_html_after.to_owned(); + + let app_state = AppState { + index_html_before, + index_html_after, + resolver: resolver_prop, + }; + + let app = Router::new() + .route( + LINK_ENDPOINT, + post(linked_state_handler).with_state(arc_resolver), + ) + .fallback_service( + ServeDir::new(opts.dir) + .append_index_html_on_directories(false) + .fallback(get(render).with_state(app_state).into_service()), + ) + .layer(CorsLayer::permissive()); + + let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); + println!("You can view the website at: http://localhost:8080/"); + + let listener = TcpListener::bind(addr).await?; + + // Continuously accept new connections. + loop { + // In this example we discard the remote address. See `fn serve_with_connect_info` for how + // to expose that. + let (socket, _remote_addr) = listener.accept().await.unwrap(); + + // We don't need to call `poll_ready` because `Router` is always ready. + let tower_service = app.clone(); + + let exec = exec.clone(); + // Spawn a task to handle the connection. That way we can handle multiple connections + // concurrently. + tokio::spawn(async move { + // Hyper has its own `AsyncRead` and `AsyncWrite` traits and doesn't use tokio. + // `TokioIo` converts between them. + let socket = TokioIo::new(socket); + + // Hyper also has its own `Service` trait and doesn't use tower. We can use + // `hyper::service::service_fn` to create a hyper `Service` that calls our app through + // `tower::Service::call`. + let hyper_service = hyper::service::service_fn(move |request: Request| { + // We have to clone `tower_service` because hyper's `Service` uses `&self` whereas + // tower's `Service` requires `&mut self`. + // + // We don't need to call `poll_ready` since `Router` is always ready. + tower_service.clone().call(request) + }); + + // `server::conn::auto::Builder` supports both http1 and http2. + // + // `TokioExecutor` tells hyper to use `tokio::spawn` to spawn tasks. + if let Err(err) = server::conn::auto::Builder::new(exec) + // `serve_connection_with_upgrades` is required for websockets. If you don't need + // that you can use `serve_connection` instead. + .serve_connection_with_upgrades(socket, hyper_service) + .await + { + eprintln!("failed to serve connection: {err:#}"); + } + }); + } +} diff --git a/examples/axum_ssr_router/src/lib.rs b/examples/axum_ssr_router/src/lib.rs new file mode 100644 index 00000000000..3c20d42c984 --- /dev/null +++ b/examples/axum_ssr_router/src/lib.rs @@ -0,0 +1,468 @@ +use std::collections::HashMap; + +use function_router::content; +#[cfg(not(target_arch = "wasm32"))] +use function_router::Generated; +use serde::{Deserialize, Serialize}; +use yew::prelude::*; +use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp}; +use yew_router::prelude::*; + +pub const LINK_ENDPOINT: &str = "/api/link"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPost(pub content::Post); + +#[linked_state] +impl LinkedState for LinkedPost { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Post::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedAuthor(pub content::Author); + +#[linked_state] +impl LinkedState for LinkedAuthor { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Author::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPostMeta(pub content::PostMeta); + +#[linked_state] +impl LinkedState for LinkedPostMeta { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::PostMeta::generate_from_seed(*seed)) + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct PostProps { + pub id: u32, +} + +#[component] +pub fn PostPage(props: &PostProps) -> HtmlResult { + let post = use_linked_state::(props.id)?.data(); + Ok(render_post(&post.0)) +} + +fn render_post(post: &content::Post) -> Html { + use content::PostPart; + + let render_quote = |quote: &content::Quote| { + html! { +
+
+

+ The author's profile +

+
+
+
+ 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! { +
+ Section image +
+
+

{ §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! { + <> +
+ Hero background +
+
+

{ &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! { +
+
+
+
+

{ &author.name }

+
+
+
+
+
+

{ "Interests" }

+
+ for tag in &author.keywords { + { tag } + } +
+
+
+
+
+ Profile picture +
+
+
+
+
+

{ "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! { +
+
+
+ This post's image +
+
+
+ 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! { +
+
+
+
+
+ Author's profile picture +
+
+
+

{ &author.name }

+

+ { "I like " } + { author.keywords.join(", ") } +

+
+
+
+
+ classes={classes!("card-footer-item")} to={function_router::Route::Author { id: author.seed }}> + { "Profile" } + > +
+
+ }) +} + +const ITEMS_PER_PAGE: u32 = 10; +const TOTAL_PAGES: u32 = u32::MAX / ITEMS_PER_PAGE; + +#[component] +fn LinkedPostList() -> Html { + use function_router::components::pagination::{PageQuery, Pagination}; + + let location = use_location().unwrap(); + let current_page = location.query::().map(|it| it.page).unwrap_or(1); + + let start_seed = (current_page - 1) * ITEMS_PER_PAGE; + let half = ITEMS_PER_PAGE / 2; + + html! { +
+

{ "Posts" }

+

{ "All of our quality writing in one place" }

+
+
+
    + for offset in 0..half { +
  • +
    {"Loading..."}
}}> + + + + } + +
+
+
    + for offset in half..ITEMS_PER_PAGE { +
  • +
    {"Loading..."}
}}> + + + + } + +
+ + + + } +} + +#[component] +fn LinkedAuthorList() -> Html { + let seeds = use_state(|| { + use rand::{distr, Rng}; + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect::>() + }); + + let on_complete = { + let seeds = seeds.clone(); + Callback::from(move |_| { + use rand::{distr, Rng}; + seeds.set( + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect(), + ); + }) + }; + + html! { +
+
+
+
+

{ "Authors" }

+

+ { "Meet the definitely real people behind your favourite Yew content" } +

+
+
+
+

+ { "It wouldn't be fair " } + { "(or possible :P)" } + {" to list each and every author in alphabetical order."} +
+ { "So instead we chose to put more focus on the individuals by introducing you to two people at a time" } +

+
+
+ for seed in seeds.iter().copied() { +
+
+
{"Loading..."}
}}> + + +
+
+ } +
+ +
+ + } +} + +fn switch(routes: function_router::Route) -> Html { + use function_router::Route; + + match routes { + Route::Post { id } => html! { + {"Loading post..."}

}}> + +
+ }, + Route::Author { id } => html! { + {"Loading author..."}

}}> + +
+ }, + Route::Posts => html! { }, + Route::Authors => html! { }, + Route::Home => html! { }, + Route::NotFound => html! { }, + } +} + +#[derive(Properties, PartialEq)] +pub struct AppProps { + pub endpoint: AttrValue, +} + +#[component] +pub fn App(props: &AppProps) -> Html { + html! { + + + +
+ render={switch} /> +
+ +
+
+ } +} + +#[derive(Properties, PartialEq, Debug)] +pub struct ServerAppProps { + pub url: AttrValue, + pub queries: HashMap, + pub resolver: ResolverProp, +} + +#[component] +pub fn ServerApp(props: &ServerAppProps) -> Html { + use yew_router::history::{AnyHistory, History, MemoryHistory}; + + let history = AnyHistory::from(MemoryHistory::new()); + history + .push_with_query(&*props.url, &props.queries) + .unwrap(); + + html! { + + + +
+ render={switch} /> +
+ +
+
+ } +} diff --git a/examples/axum_ssr_router/tests/e2e.rs b/examples/axum_ssr_router/tests/e2e.rs new file mode 100644 index 00000000000..39887013cf1 --- /dev/null +++ b/examples/axum_ssr_router/tests/e2e.rs @@ -0,0 +1,199 @@ +#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + +use gloo::utils::document; +use ssr_e2e_harness::{ + clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, + wait_for, +}; +use ssr_router::{App, AppProps, LINK_ENDPOINT}; +use wasm_bindgen::JsCast; +use wasm_bindgen_test::*; +use yew::Renderer; + +wasm_bindgen_test_configure!(run_in_browser); + +const SERVER_BASE: &str = "http://127.0.0.1:8080"; + +fn endpoint() -> String { + format!("{SERVER_BASE}{LINK_ENDPOINT}") +} + +fn make_renderer() -> Renderer { + Renderer::::with_root_and_props( + output_element(), + AppProps { + endpoint: endpoint().into(), + }, + ) +} + +fn get_title_text() -> Option { + document() + .query_selector("h1.title") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) +} + +fn post_body_text() -> String { + output_element() + .query_selector(".section.container") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) + .unwrap_or_default() +} + +fn extract_text_from_html(html: &str, selector: &str) -> Option { + let container = document().create_element("div").unwrap(); + container.set_inner_html(html); + container + .query_selector(selector) + .ok() + .flatten() + .and_then(|el| el.text_content()) +} + +#[wasm_bindgen_test] +async fn ssr_hydration_and_client_navigation() { + // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link -- + + let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await; + let ssr_title = extract_text_from_html(&ssr_html, "h1.title") + .expect("SSR HTML for /posts/0 should contain h1.title"); + let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); + + clear_resource_timings(); + + output_element().set_inner_html(&ssr_html); + ssr_e2e_harness::push_route("/posts/0"); + let app = make_renderer().hydrate(); + + wait_for( + || { + let html = output_element().inner_html(); + html.contains("

") && !html.contains("Loading post...") + }, + 5000, + "post page content after SSR hydration", + ) + .await; + + let link_fetches = resource_request_count(LINK_ENDPOINT); + let title = get_title_text(); + + assert_eq!( + link_fetches, 0, + "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}" + ); + let title = title.expect("h1.title should be present on the SSR post page"); + assert!(!title.is_empty(), "SSR post title should not be empty"); + + // -- Part 2: Navigate to /posts within the same app, then to /posts/0 -- + + yew::scheduler::flush().await; + + clear_resource_timings(); + + let posts_link: web_sys::HtmlElement = output_element() + .query_selector("a.navbar-item[href='/posts']") + .unwrap() + .expect("Posts navbar link should exist") + .dyn_into() + .unwrap(); + posts_link.click(); + yew::scheduler::flush().await; + + wait_for( + || { + document() + .query_selector("a.title.is-block") + .ok() + .flatten() + .is_some() + && get_title_text().as_deref() == Some("Posts") + }, + 15000, + "posts list after client-side navigation to /posts", + ) + .await; + + clear_resource_timings(); + + wait_for( + || { + output_element() + .query_selector("a.title.is-block[href='/posts/0']") + .ok() + .flatten() + .is_some() + }, + 15000, + "post 0 card link on posts list", + ) + .await; + + let post_link: web_sys::HtmlElement = output_element() + .query_selector("a.title.is-block[href='/posts/0']") + .unwrap() + .unwrap() + .dyn_into() + .unwrap(); + post_link.click(); + yew::scheduler::flush().await; + + wait_for( + || { + document() + .query_selector("h2.subtitle") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) + .is_some_and(|text| text.starts_with("by ")) + }, + 15000, + "post page content after client-side navigation to /posts/0", + ) + .await; + + // -- Part 3: Verify fetch happened and content matches SSR -- + + let nav_link_fetches = resource_request_count(LINK_ENDPOINT); + let nav_title = get_title_text(); + let nav_body = post_body_text(); + + assert!( + nav_link_fetches >= 1, + "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \ + got {nav_link_fetches}" + ); + + let nav_title = nav_title.expect("h1.title should be present after client-side navigation"); + assert_eq!( + ssr_title, nav_title, + "post title should match between SSR and client-side navigation" + ); + assert_eq!( + ssr_body, nav_body, + "post body should match between SSR and client-side navigation" + ); + + app.destroy(); + yew::scheduler::flush().await; +} + +#[wasm_bindgen_test] +async fn hydrate_home() { + setup_ssr_page(SERVER_BASE, "/").await; + let app = make_renderer().hydrate(); + + wait_for( + || output_element().inner_html().contains("Welcome"), + 5000, + "home page content after hydration", + ) + .await; + + app.destroy(); + yew::scheduler::flush().await; +} From 686fc3d20c498e04037bc7dc5185ef60e7b372af Mon Sep 17 00:00:00 2001 From: stifskere Date: Mon, 6 Apr 2026 19:57:33 +0200 Subject: [PATCH 07/16] docs: #4113 add the example changes to examples/readme.md --- examples/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index 01c7d15d333..8053fc64743 100644 --- a/examples/README.md +++ b/examples/README.md @@ -58,7 +58,8 @@ As an example, check out the TodoMVC example here: ` support. | | [timer](timer) | [S] | Demonstrates the use of the interval and timeout services. | | [timer_functional](timer_functional) | [F] | Demonstrates the use of the interval and timeout services using function components | From bccf5d07de7cdccd5f8878984c22862479133229 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 7 Apr 2026 17:26:47 +0900 Subject: [PATCH 08/16] fix: bump actix_ssr_router to edition 2024 and fmt --- examples/actix_ssr_router/Cargo.toml | 3 ++- examples/actix_ssr_router/src/bin/ssr_router_server.rs | 6 +++--- examples/actix_ssr_router/src/lib.rs | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml index 7b4759f4640..20f3e4f6582 100644 --- a/examples/actix_ssr_router/Cargo.toml +++ b/examples/actix_ssr_router/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "actix_ssr_router" version = "0.1.0" -edition = "2021" +edition = "2024" +rust-version.workspace = true [[bin]] name = "ssr_router_hydrate" diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs index e18f7db05a2..2c704eb2ce3 100644 --- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs +++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs @@ -3,14 +3,14 @@ use std::io::Result as IoResult; use std::path::PathBuf; use actix_ssr_router::{ - LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, LINK_ENDPOINT, + LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, }; use actix_web::http::Uri; -use actix_web::web::{get, post, Data, Query}; +use actix_web::web::{Data, Query, get, post}; use actix_web::{App, Error, HttpResponse, HttpServer}; use bytes::Bytes; use clap::Parser; -use function_router::{route_meta, Route}; +use function_router::{Route, route_meta}; use futures::stream::{self, StreamExt}; use yew_link::actix_web::linked_state_handler; use yew_link::{Resolver, ResolverProp}; diff --git a/examples/actix_ssr_router/src/lib.rs b/examples/actix_ssr_router/src/lib.rs index 8d786480e46..c0103893e9c 100644 --- a/examples/actix_ssr_router/src/lib.rs +++ b/examples/actix_ssr_router/src/lib.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; -use function_router::content; #[cfg(not(target_arch = "wasm32"))] use function_router::Generated; +use function_router::content; use rand::RngExt; use serde::{Deserialize, Serialize}; use yew::prelude::*; -use yew_link::{linked_state, use_linked_state, LinkProvider, ResolverProp}; +use yew_link::{LinkProvider, ResolverProp, linked_state, use_linked_state}; use yew_router::prelude::*; pub const LINK_ENDPOINT: &str = "/api/link"; From 26323c548d901ce25b128ac890a9eff446607516 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 7 Apr 2026 17:34:21 +0900 Subject: [PATCH 09/16] ci: update SSR E2E matrix for renamed axum_ssr_router and new actix_ssr_router --- .github/workflows/main-checks.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml index 89f6a20a491..a6022fdda3d 100644 --- a/.github/workflows/main-checks.yml +++ b/.github/workflows/main-checks.yml @@ -301,9 +301,12 @@ jobs: fail-fast: false matrix: include: - - example: ssr_router + - example: axum_ssr_router server_bin: ssr_router_server - trunk_dir: examples/ssr_router + trunk_dir: examples/axum_ssr_router + - example: actix_ssr_router + server_bin: ssr_router_server + trunk_dir: examples/actix_ssr_router - example: simple_ssr server_bin: simple_ssr_server trunk_dir: examples/simple_ssr From 423a77ef0aa465f11c7ff4d54fab53b970a09921 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 7 Apr 2026 17:45:01 +0900 Subject: [PATCH 10/16] fix: update e2e tests to import from renamed crates --- examples/actix_ssr_router/tests/e2e.rs | 2 +- examples/axum_ssr_router/tests/e2e.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/actix_ssr_router/tests/e2e.rs b/examples/actix_ssr_router/tests/e2e.rs index 39887013cf1..0b5b18b38e0 100644 --- a/examples/actix_ssr_router/tests/e2e.rs +++ b/examples/actix_ssr_router/tests/e2e.rs @@ -1,11 +1,11 @@ #![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] +use actix_ssr_router::{App, AppProps, LINK_ENDPOINT}; use gloo::utils::document; use ssr_e2e_harness::{ clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, wait_for, }; -use ssr_router::{App, AppProps, LINK_ENDPOINT}; use wasm_bindgen::JsCast; use wasm_bindgen_test::*; use yew::Renderer; diff --git a/examples/axum_ssr_router/tests/e2e.rs b/examples/axum_ssr_router/tests/e2e.rs index 39887013cf1..9bf3e10a8c3 100644 --- a/examples/axum_ssr_router/tests/e2e.rs +++ b/examples/axum_ssr_router/tests/e2e.rs @@ -1,11 +1,11 @@ #![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] +use axum_ssr_router::{App, AppProps, LINK_ENDPOINT}; use gloo::utils::document; use ssr_e2e_harness::{ clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, wait_for, }; -use ssr_router::{App, AppProps, LINK_ENDPOINT}; use wasm_bindgen::JsCast; use wasm_bindgen_test::*; use yew::Renderer; From 5990ef81d170d0cf51976027f97588d7cb15a3cd Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 7 Apr 2026 18:26:40 +0900 Subject: [PATCH 11/16] fix: actix_ssr_router needs CORS and static file serving for e2e --- Cargo.lock | 52 +++++++++++++++++++ examples/actix_ssr_router/Cargo.toml | 2 + .../src/bin/ssr_router_server.rs | 10 +++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4ee7144db89..167b5ff36e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,44 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.12.0" @@ -189,6 +227,8 @@ dependencies = [ name = "actix_ssr_router" version = "0.1.0" dependencies = [ + "actix-cors", + "actix-files", "actix-web", "bytes", "clap", @@ -1938,6 +1978,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "http-range-header" version = "0.4.2" @@ -4400,6 +4446,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "valuable" version = "0.1.1" diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml index 20f3e4f6582..41ec5167978 100644 --- a/examples/actix_ssr_router/Cargo.toml +++ b/examples/actix_ssr_router/Cargo.toml @@ -34,6 +34,8 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs" env_logger = "0.11" clap = { workspace = true } actix-web = "4.13.0" +actix-cors = "0.7" +actix-files = "0.6" [target.'cfg(unix)'.dependencies] jemallocator = "0.5" diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs index 2c704eb2ce3..5dc9a4959a5 100644 --- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs +++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::io::Result as IoResult; use std::path::PathBuf; +use actix_cors::Cors; +use actix_files::Files; use actix_ssr_router::{ LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, }; @@ -100,11 +102,17 @@ async fn main() -> IoResult<()> { .into(), }); + let dir = opts.dir.clone(); HttpServer::new(move || { App::new() + .wrap(Cors::permissive()) .app_data(app_state.clone()) .route(LINK_ENDPOINT, post().to(linked_state_handler)) - .default_service(get().to(render)) + .service( + Files::new("/", &dir) + .index_file("__no_index__") + .default_handler(get().to(render)), + ) }) .bind(("0.0.0.0", 8080))? .run() From 582df96659b2b1770bae3e497c43cf68545f43fa Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 7 Apr 2026 19:02:28 +0900 Subject: [PATCH 12/16] fix: actix_ssr_router resolver registration and HTML content type The linked_state_handler in yew-link extracts Data, but the server only registered Data, returning 500. Install the inner Arc as Data via app_data alongside the AppState. Also set content_type=text/html on the SSR streaming response so browsers render it instead of downloading it. --- .../src/bin/ssr_router_server.rs | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs index 5dc9a4959a5..605c68ae728 100644 --- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs +++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs @@ -68,14 +68,16 @@ async fn render( resolver, }); - HttpResponse::Ok().streaming( - stream::once(async move { Bytes::from(before) }) - .chain(renderer.render_stream().map(Bytes::from)) - .chain(stream::once(async move { - Bytes::from(state.index_html_after.clone()) - })) - .map(Ok::), - ) + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .streaming( + stream::once(async move { Bytes::from(before) }) + .chain(renderer.render_stream().map(Bytes::from)) + .chain(stream::once(async move { + Bytes::from(state.index_html_after.clone()) + })) + .map(Ok::), + ) } #[actix_web::main] @@ -92,14 +94,17 @@ async fn main() -> IoResult<()> { index_html_before.push_str(""); let index_html_after = index_html_after.to_owned(); + let resolver_prop: ResolverProp = Resolver::new() + .register_linked::(()) + .register_linked::(()) + .register_linked::(()) + .into(); + let resolver_data = Data::from(resolver_prop.0.clone()); + let app_state = Data::new(AppState { index_html_before, index_html_after, - resolver: Resolver::new() - .register_linked::(()) - .register_linked::(()) - .register_linked::(()) - .into(), + resolver: resolver_prop, }); let dir = opts.dir.clone(); @@ -107,6 +112,7 @@ async fn main() -> IoResult<()> { App::new() .wrap(Cors::permissive()) .app_data(app_state.clone()) + .app_data(resolver_data.clone()) .route(LINK_ENDPOINT, post().to(linked_state_handler)) .service( Files::new("/", &dir) From 30ec78ead00b2a890f82e81394be01573ccc05b6 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 7 Apr 2026 21:39:04 +0900 Subject: [PATCH 13/16] fix: feature soundness, CI, website i18n --- .github/workflows/clippy.yml | 4 +- .github/workflows/main-checks.yml | 2 +- Cargo.lock | 19 ++- Cargo.toml | 2 + examples/actix_ssr_router/Cargo.toml | 2 +- .../src/bin/ssr_router_server.rs | 2 +- packages/yew-link/Cargo.toml | 7 +- packages/yew-link/src/lib.rs | 9 +- packages/yew-link/src/services.rs | 2 +- tools/website-test/Cargo.toml | 3 + .../advanced-topics/server-side-rendering.mdx | 67 ++++++-- .../advanced-topics/server-side-rendering.mdx | 154 +++++++++++++++++- .../advanced-topics/server-side-rendering.mdx | 154 +++++++++++++++++- .../advanced-topics/server-side-rendering.mdx | 154 +++++++++++++++++- 14 files changed, 539 insertions(+), 42 deletions(-) diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 62ef8c6fa46..7dd10a983df 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -44,7 +44,7 @@ jobs: if: matrix.profile == 'dev' run: >- cargo hack clippy - -p yew -p yew-agent -p yew-router + -p yew -p yew-agent -p yew-router -p yew-link --feature-powerset --no-dev-deps --keep-going -- -D warnings @@ -53,7 +53,7 @@ jobs: if: matrix.profile == 'release' run: >- cargo hack clippy - -p yew -p yew-agent -p yew-router + -p yew -p yew-agent -p yew-router -p yew-link --feature-powerset --no-dev-deps --keep-going --release -- -D warnings diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml index a6022fdda3d..6f2f9fb8dc9 100644 --- a/.github/workflows/main-checks.yml +++ b/.github/workflows/main-checks.yml @@ -144,7 +144,7 @@ jobs: RUSTFLAGS: ${{ matrix.toolchain == 'nightly' && '--cfg nightly_yew' || '' }} run: | if [[ "${{ matrix.toolchain }}" == "1.85.0" ]]; then - cargo test --all-targets -p yew-agent -p yew-agent-macro -p yew-router + cargo test --all-targets -p yew-agent -p yew-agent-macro -p yew-router -p yew-link -p yew-link-macro else ls packages | grep -v "^yew$" | xargs -I {} cargo test --all-targets -p {} fi diff --git a/Cargo.lock b/Cargo.lock index 167b5ff36e1..a4793033d7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,9 +59,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.12.0" +version = "3.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" dependencies = [ "actix-codec", "actix-rt", @@ -170,9 +170,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" dependencies = [ "actix-codec", "actix-http", @@ -2552,9 +2552,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.27" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3884c0d1cee1f72141b54543005490614e7f96890d10c99a238a54cbd0e43d81" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "libc", @@ -4365,9 +4365,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -4765,12 +4765,14 @@ dependencies = [ name = "website-test" version = "0.1.0" dependencies = [ + "actix-web", "derive_more", "glob", "gloo", "gloo-net 0.7.0", "js-sys", "serde", + "serde_json", "tokio", "wasm-bindgen", "wasm-bindgen-futures", @@ -4779,6 +4781,7 @@ dependencies = [ "yew", "yew-agent", "yew-autoprops", + "yew-link", "yew-router", ] diff --git a/Cargo.toml b/Cargo.toml index 18c98b23072..05b3cf7a5c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,3 +66,5 @@ thiserror = "2.0" bincode = { version = "2.0.0-rc.3", features = ["serde"] } reqwest = "0.13" axum = "0.8" +# 4.13 requires rustc > 1.85 +actix-web = "<4.13" diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml index 41ec5167978..6cf2e181194 100644 --- a/examples/actix_ssr_router/Cargo.toml +++ b/examples/actix_ssr_router/Cargo.toml @@ -33,7 +33,7 @@ tracing-subscriber.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] } env_logger = "0.11" clap = { workspace = true } -actix-web = "4.13.0" +actix-web = { workspace = true } actix-cors = "0.7" actix-files = "0.6" diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs index 605c68ae728..43569b7831f 100644 --- a/examples/actix_ssr_router/src/bin/ssr_router_server.rs +++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs @@ -14,7 +14,7 @@ use bytes::Bytes; use clap::Parser; use function_router::{Route, route_meta}; use futures::stream::{self, StreamExt}; -use yew_link::actix_web::linked_state_handler; +use yew_link::actix::linked_state_handler; use yew_link::{Resolver, ResolverProp}; use yew_router::prelude::Routable; diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml index 6710b93c495..6b801a6c854 100644 --- a/packages/yew-link/Cargo.toml +++ b/packages/yew-link/Cargo.toml @@ -20,15 +20,14 @@ wasm-bindgen-futures = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { workspace = true, optional = true } -actix-web = { version = "4.13", optional = true } +actix-web = { workspace = true, optional = true } [features] default = [] ssr = ["yew/ssr"] hydration = ["yew/hydration"] -axum = ["dep:axum", "services"] -actix = ["dep:actix-web", "services"] -services = [] +axum = ["dep:axum"] +actix = ["dep:actix-web"] [lints] workspace = true diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs index d31901b11fa..f889f5291c9 100644 --- a/packages/yew-link/src/lib.rs +++ b/packages/yew-link/src/lib.rs @@ -511,6 +511,7 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult().expect("use_linked_state requires a LinkProvider"); + #[cfg(any(feature = "ssr", target_arch = "wasm32"))] type Prepared = Result>; #[cfg(feature = "ssr")] @@ -540,9 +541,7 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult, T::Input>( - input, - )?; + let _ = input; Ok(LinkedStateHandle { result: Err(LinkError::Internal( "yew-link requires the `ssr` feature (server) or a wasm32 target (client)".into(), @@ -678,8 +677,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult(db_pool.clone()) + Resolver::new() + .register_linked::(db_pool.clone()) ); HttpServer::new(move || { - App::new() - .route("/api/link", post().to(linked_state_handler)) - .app_data(resolver) + App::new() + .app_data(resolver.clone()) + .route("/api/link", post().to(linked_state_handler)) }) - .bind(("0.0.0.0", 8080))? - .run() - .await +.bind(("0.0.0.0", 8080))? +.run() +.await +``` + +
+Why `actix-web` is pinned to 4.12, and how to implement a handler for other server frameworks + +The `actix` feature is pinned to `actix-web 4.12.x` because Yew's MSRV is +`1.85`, while `actix-web 4.13` and newer require `rustc 1.88`. This pin lives +inside `yew-link` only so the rest of the workspace stays buildable on the +supported toolchain; it does not constrain what version of `actix-web` you +use elsewhere in your own application. + +If you need a newer `actix-web` (or any other web framework) and do not want +to enable the bundled feature, the handler is small enough to inline +yourself. The only public surface you need is `Resolver::resolve_request` +and the wire-format type `LinkRequest`: + +```rust +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data, + Json(req): Json, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} ``` +The same shape works for `axum`, `warp`, `rocket`, or any framework that can +deserialize JSON into `LinkRequest`, call an async function, and serialize a +JSON response. Construct the response wire format with `serde_json::json!` +to keep yew-link's internal `LinkResponse` type out of your dependency +surface. + +
+ #### Component usage ```rust ,ignore @@ -247,7 +287,7 @@ fn PostPage(props: &PostPageProps) -> HtmlResult { During SSR the state is resolved locally via the `Resolver` and embedded in the HTML through `use_prepared_state`. On hydration the client reads the embedded state with zero network requests. On subsequent client-side navigations the hook fetches from the `LinkProvider`'s endpoint URL automatically. -See the [`ssr_router` example](https://github.com/yewstack/yew/tree/master/examples/ssr_router) for a full working demo. +See the [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) and [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) examples for full working demos. ## Rendering `` Tags @@ -259,7 +299,7 @@ load. it has no access to ``. Head tags must therefore be generated **on the server, outside of Yew**, and spliced into the HTML template before it is sent to the client. -The [`ssr_router` example](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) demonstrates this pattern: the server recognizes the +The [`axum_ssr_router` example](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) demonstrates this pattern: the server recognizes the route from the request URL, generates the appropriate `` and `<meta>` tags, and injects them into the Trunk-generated `index.html` before `</head>`. @@ -335,7 +375,8 @@ fn main() { ``` Example: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -Example: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +Example: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +Example: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## Single thread mode diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index 6496ca5962c..3df36e9cfde 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -84,13 +84,162 @@ Yewは、`<Suspense />` を使用してこの問題を解決する異なるア この方法により、開発者はサーバーサイドレンダリングに対応したクライアント非依存のアプリケーションを簡単に構築し、データ取得を行うことができます。 +### 低レベルフック + +Yew は、サーバー側で計算した状態をクライアントへ運ぶための低レベルフックを 2 つ提供しています: + +- **`use_prepared_state!`** は SSR 中に(必要であれば async な)クロージャを実行し、結果をシリアライズして、ハイドレーション中にクライアントへ届けます。コンポーネントが最初のレンダリング時に必要とするデータの取得に向いています。 +- **`use_transitive_state!`** も同様ですが、クロージャはコンポーネントの SSR 出力が生成された _後_ に実行されます。キャッシュや集約状態の収集に向いています。 + +どちらも内部で `bincode` と `base64` を使い、HTML に `<script>` タグとして埋め込まれます。 + +### `yew-link`:統一されたデータ取得 + +低レベルフックは **初回のページ読み込み**(SSR からハイドレーションまで)を扱いますが、ハイドレーション後のクライアントサイドナビゲーションには別のデータ取得経路(例えば `use_future_with` と HTTP クライアントの組み合わせ)が必要です。これは同じデータロジックを 2 回書くことを意味します。 + +`yew-link` クレートは、これら 3 つの経路(SSR、ハイドレーション、クライアントサイドナビゲーション)を 1 つのフックの背後で統一する高水準の抽象を提供します: + +1. `#[linked_state]` 属性マクロで自分のデータ型を **定義** します。 +2. サーバー側で resolver を **登録** します。 +3. アプリを `<LinkProvider>` で **包みます**。 +4. 任意のコンポーネントから `use_linked_state::<MyData>(input)` を **呼び出します**。 + +```rust ,ignore +use yew_link::{linked_state, LinkedState}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Post { pub title: String, pub body: String } + +#[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 + } +} +``` + +このマクロは `LinkedState` と(サーバー専用の)`LinkedStateResolve` トレイト実装を生成します。`resolve` の本体は WASM バンドルから自動的に取り除かれます。 + +#### 型付きエラー + +`resolve` が失敗する可能性がある場合は、`type Error` を宣言します: + +```rust ,ignore +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + type Error = ApiError; + + async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> { + ctx.get_post(*id).await.map_err(ApiError::from) + } +} +``` + +`type Error` を省略した場合、エラー型は実体を持たない `Never` 型がデフォルトとなり、`resolve` の本体は自動的に `Ok(…)` で包まれます。 + +`use_linked_state` は `SuspensionResult<LinkedStateHandle<T>>` を返します。外側はサスペンス(読み込み中)を表します。ハンドルは以下を提供します: + +- **`.data()`** は解決済みの `Rc<T>` を返します。エラーがあれば panic します。 +- **`.as_result()`** は内部の `Result<Rc<T>, LinkError<T::Error>>` を借用するので、パターンマッチで扱えます。 +- **`.refresh()`** はバックグラウンドで再取得を起動しつつ、それまでの(古い)値を表示し続けます(stale-while-revalidate)。 +- **`.is_refreshing()`** はバックグラウンドのリフレッシュが進行中の間 `true` を返すので、古いデータの隣にローディングインジケータを表示できます。 + +`LinkError` は、アプリケーションレベルのエラー(`LinkError::Resolve`)とインフラ層の障害(`LinkError::Internal`)を区別します。 + +複数のコンポーネントが同じ `(T, Input)` を同時に要求した場合、それらは進行中の 1 つのリクエストを自動的に共有します。 + +#### サーバー側の設定 + +```rust ,ignore +use yew_link::{Resolver, axum::linked_state_handler}; + +let resolver = Arc::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +let app = axum::Router::new() + .route("/api/link", axum::routing::post(linked_state_handler)) + .with_state(resolver); +``` + +上の例では `axum` 機能を使っています。`yew-link` は `actix` 機能も提供しており、同じハンドラを `yew_link::actix::linked_state_handler` 経由で公開します: + +```rust ,ignore +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +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 +``` + +<details> +<summary>なぜ `actix-web` が 4.12 に固定されているのか、他のサーバーフレームワーク向けにハンドラを実装する方法</summary> + +`actix` 機能は `actix-web 4.12.x` に固定されています。これは Yew の MSRV が `1.85` であるのに対し、`actix-web 4.13` 以降は `rustc 1.88` を必要とするためです。このバージョン固定は `yew-link` の内部にだけ存在し、ワークスペースの他の部分がサポートされたツールチェインでビルドできるようにすることが目的です。あなた自身のアプリケーションの他の場所でどのバージョンの `actix-web` を使うかには影響しません。 + +より新しい `actix-web`(あるいは他の web フレームワーク)が必要で、同梱の機能を有効にしたくない場合、ハンドラ自体は十分に小さいので自分でインライン実装できます。必要となる公開 API は `Resolver::resolve_request` と、ワイヤーフォーマットの型 `LinkRequest` だけです: + +```rust +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data<Resolver>, + Json(req): Json<LinkRequest>, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +同じ書き方は `axum`、`warp`、`rocket`、あるいは JSON を `LinkRequest` にデシリアライズし、async 関数を呼び出して JSON レスポンスをシリアライズできる任意のフレームワークで動作します。レスポンスのワイヤーフォーマットは `serde_json::json!` で構築すると、yew-link 内部の `LinkResponse` 型を依存範囲に含めずに済みます。 + +</details> + +#### コンポーネント内での利用 + +```rust ,ignore +use yew_link::{use_linked_state, LinkProvider}; + +#[component] +fn PostPage(props: &PostPageProps) -> HtmlResult { + let post = use_linked_state::<Post>(props.id)?.data(); + Ok(html! { <h1>{ &post.title }</h1> }) +} +``` + +SSR 中、状態は `Resolver` を通じてサーバー側でローカルに解決され、`use_prepared_state` を介して HTML に埋め込まれます。ハイドレーション時、クライアントは埋め込まれた状態をネットワークリクエストなしに直接読み取ります。その後のクライアントサイドナビゲーションでは、フックは `LinkProvider` の `endpoint` URL から自動的にデータを取得します。 + +完全に動作するデモは [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) と [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) のサンプルを参照してください。 + ## `<head>` タグのレンダリング SSR でよく必要とされるのは、クローラーやソーシャルプレビューが最初のロード時に正しいメタデータを参照できるよう、動的な `<head>` コンテンツ(`<title>`、`<meta>` など)をレンダリングすることです。 `ServerRenderer` はコンポーネントツリー(通常はドキュメントの body 部分)のみをレンダリングし、`<head>` にはアクセスできません。そのため、head タグは **Yew の外部でサーバー側に**生成し、クライアントに送信する前に HTML テンプレートに埋め込む必要があります。 -[`ssr_router` サンプル](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) はこのパターンを示しています:サーバーはリクエスト URL からルートを判別し、適切な `<title>` および `<meta>` タグを生成して、Trunk が生成した `index.html` の `</head>` の前に挿入します。 +[`axum_ssr_router` サンプル](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) はこのパターンを示しています:サーバーはリクエスト URL からルートを判別し、適切な `<title>` および `<meta>` タグを生成して、Trunk が生成した `index.html` の `</head>` の前に挿入します。 :::info @@ -138,7 +287,8 @@ fn main() { ``` 例: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -例: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +例: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +例: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## シングルスレッドモード diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index f19269b66de..4539bb588e2 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -84,13 +84,162 @@ Yew 采用了一种不同的方法,通过 `<Suspense />` 来解决这个问题 通过这种方法,开发人员可以轻松构建一个准备好进行服务端渲染的、与客户端无关的应用程序,并进行数据获取。 +### 低层钩子 + +Yew 提供了两个低层钩子,用于将服务端计算出的状态传递到客户端: + +- **`use_prepared_state!`** 在 SSR 期间运行一个(可选 async 的)闭包,将结果序列化,并在水合期间传递到客户端。适合获取组件首次渲染所需的数据。 +- **`use_transitive_state!`** 类似,但闭包会在组件的 SSR 输出生成 _之后_ 才运行。适合收集缓存或汇总状态。 + +两者底层都使用 `bincode` 加 `base64`,并以 `<script>` 标签的形式嵌入到 HTML 中。 + +### `yew-link`:统一的数据获取 + +低层钩子能处理 **首次页面加载**(从 SSR 到水合),但水合之后的客户端导航需要一条独立的数据获取路径(例如 `use_future_with` 加上一个 HTTP 客户端)。这意味着同一份数据逻辑要写两遍。 + +`yew-link` crate 提供了一个更高层的抽象,将所有三条路径(SSR、水合和客户端导航)统一在同一个钩子背后: + +1. 使用 `#[linked_state]` 属性宏 **定义** 你的数据类型。 +2. 在服务端 **注册** 一个 resolver。 +3. 用 `<LinkProvider>` **包裹** 你的应用。 +4. 在任意组件中 **调用** `use_linked_state::<MyData>(input)`。 + +```rust ,ignore +use yew_link::{linked_state, LinkedState}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Post { pub title: String, pub body: String } + +#[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 + } +} +``` + +该宏会生成 `LinkedState` 以及(仅服务端的)`LinkedStateResolve` trait 实现。`resolve` 函数体会自动从 WASM 包中剥离。 + +#### 类型化错误 + +如果 `resolve` 可能失败,可以声明 `type Error`: + +```rust ,ignore +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + type Error = ApiError; + + async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> { + ctx.get_post(*id).await.map_err(ApiError::from) + } +} +``` + +省略 `type Error` 时,错误类型默认为不可实例化的 `Never`,并且 `resolve` 函数体会被自动包装在 `Ok(…)` 中。 + +`use_linked_state` 返回 `SuspensionResult<LinkedStateHandle<T>>`。外层用于挂起(加载中)。该句柄提供: + +- **`.data()`** 返回解析后的 `Rc<T>`,遇到错误时会 panic。 +- **`.as_result()`** 借用底层的 `Result<Rc<T>, LinkError<T::Error>>`,方便进行模式匹配。 +- **`.refresh()`** 触发后台重新拉取,同时仍然显示之前(旧)的值(stale-while-revalidate)。 +- **`.is_refreshing()`** 当后台刷新进行中时返回 `true`,便于在旧数据旁边显示加载指示器。 + +`LinkError` 区分了应用层错误(`LinkError::Resolve`)与基础设施错误(`LinkError::Internal`)。 + +如果多个组件并发请求相同的 `(T, Input)`,它们会自动共用同一个进行中的请求。 + +#### 服务端设置 + +```rust ,ignore +use yew_link::{Resolver, axum::linked_state_handler}; + +let resolver = Arc::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +let app = axum::Router::new() + .route("/api/link", axum::routing::post(linked_state_handler)) + .with_state(resolver); +``` + +上面的例子使用了 `axum` 特性。`yew-link` 也提供了一个 `actix` 特性,将相同的 handler 暴露在 `yew_link::actix::linked_state_handler` 之下: + +```rust ,ignore +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +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 +``` + +<details> +<summary>为什么 `actix-web` 被锁定在 4.12,以及如何为其他服务器框架实现 handler</summary> + +`actix` 特性被锁定在 `actix-web 4.12.x`,因为 Yew 的 MSRV 为 `1.85`,而 `actix-web 4.13` 及更高版本要求 `rustc 1.88`。这一版本约束仅存在于 `yew-link` 内部,目的是让 workspace 的其他部分仍能在受支持的工具链上构建;它不会影响你在自己应用程序的其他地方使用哪个版本的 `actix-web`。 + +如果你需要更新版本的 `actix-web`(或者任何其他 web 框架),并且不想启用内置的特性,这个 handler 足够小,完全可以自己内联实现。你需要用到的全部公开 API 只有 `Resolver::resolve_request` 以及表示传输格式的 `LinkRequest` 类型: + +```rust +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data<Resolver>, + Json(req): Json<LinkRequest>, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +同样的写法适用于 `axum`、`warp`、`rocket`,或任何能够将 JSON 反序列化为 `LinkRequest`、调用一个 async 函数并将结果序列化为 JSON 响应的框架。使用 `serde_json::json!` 构造响应的传输格式,可以避免让 yew-link 内部的 `LinkResponse` 类型出现在你的依赖范围中。 + +</details> + +#### 组件中的使用 + +```rust ,ignore +use yew_link::{use_linked_state, LinkProvider}; + +#[component] +fn PostPage(props: &PostPageProps) -> HtmlResult { + let post = use_linked_state::<Post>(props.id)?.data(); + Ok(html! { <h1>{ &post.title }</h1> }) +} +``` + +在 SSR 期间,状态会通过 `Resolver` 在本地解析,并通过 `use_prepared_state` 嵌入到 HTML 中。水合时客户端会直接读取嵌入的状态,无需任何网络请求。在之后的客户端导航中,该钩子会自动从 `LinkProvider` 的 endpoint URL 拉取数据。 + +参见 [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) 与 [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) 示例查看完整可运行的演示。 + ## 渲染 `<head>` 标签 SSR 中的一个常见需求是渲染动态 `<head>` 内容(例如 `<title>`、`<meta>`),使爬虫和社交预览在首次加载时能看到正确的元数据。 `ServerRenderer` 只渲染组件树(通常对应文档的 body 部分),无法访问 `<head>`。因此,head 标签必须**在服务端、Yew 之外**生成,并在发送给客户端之前拼接到 HTML 模板中。 -[`ssr_router` 示例](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) 演示了这一模式:服务端从请求 URL 识别路由,生成适当的 `<title>` 和 `<meta>` 标签,并将它们注入到 Trunk 生成的 `index.html` 的 `</head>` 之前。 +[`axum_ssr_router` 示例](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) 演示了这一模式:服务端从请求 URL 识别路由,生成适当的 `<title>` 和 `<meta>` 标签,并将它们注入到 Trunk 生成的 `index.html` 的 `</head>` 之前。 :::info @@ -138,7 +287,8 @@ fn main() { ``` 示例: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -示例: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +示例: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +示例: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## 单线程模式 diff --git a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index d407744eb4f..063c018b7bc 100644 --- a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -84,13 +84,162 @@ Yew 採用了不同的方法,透過 `<Suspense />` 來解決這個問題。 透過這種方法,開發人員可以輕鬆建立一個準備好進行服務端渲染的、與客戶端無關的應用程序,並進行資料擷取。 +### 低階鉤子 + +Yew 提供了兩個低階鉤子,用於將伺服器端計算出的狀態傳遞到客戶端: + +- **`use_prepared_state!`** 在 SSR 期間執行一個(可選 async 的)閉包,將結果序列化,並在水合期間傳遞到客戶端。適合取得元件首次渲染時所需的資料。 +- **`use_transitive_state!`** 類似,但閉包會在元件的 SSR 輸出產生 _之後_ 才執行。適合收集快取或彙總狀態。 + +兩者底層都使用 `bincode` 加 `base64`,並以 `<script>` 標籤的形式嵌入到 HTML 中。 + +### `yew-link`:統一的資料取得 + +低階鉤子能處理 **首次頁面載入**(從 SSR 到水合),但水合之後的客戶端導覽需要一條獨立的資料取得路徑(例如 `use_future_with` 加上一個 HTTP 客戶端)。這意味著同一份資料邏輯要寫兩次。 + +`yew-link` crate 提供了一個更高層的抽象,將這三條路徑(SSR、水合與客戶端導覽)統一在同一個鉤子之下: + +1. 使用 `#[linked_state]` 屬性巨集 **定義** 你的資料型別。 +2. 在伺服器端 **註冊** 一個 resolver。 +3. 用 `<LinkProvider>` **包裹** 你的應用程式。 +4. 在任意元件中 **呼叫** `use_linked_state::<MyData>(input)`。 + +```rust ,ignore +use yew_link::{linked_state, LinkedState}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Post { pub title: String, pub body: String } + +#[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 + } +} +``` + +這個巨集會產生 `LinkedState` 以及(僅伺服器端的)`LinkedStateResolve` trait 實作。`resolve` 函式本體會自動從 WASM 包中移除。 + +#### 型別化錯誤 + +如果 `resolve` 可能失敗,可以宣告 `type Error`: + +```rust ,ignore +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + type Error = ApiError; + + async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> { + ctx.get_post(*id).await.map_err(ApiError::from) + } +} +``` + +省略 `type Error` 時,錯誤型別預設為無法實例化的 `Never`,並且 `resolve` 函式本體會被自動包裝在 `Ok(…)` 中。 + +`use_linked_state` 回傳 `SuspensionResult<LinkedStateHandle<T>>`。外層用於 suspense(載入中)。該 handle 提供: + +- **`.data()`** 回傳解析後的 `Rc<T>`,遇到錯誤時會 panic。 +- **`.as_result()`** 借用底層的 `Result<Rc<T>, LinkError<T::Error>>`,方便進行模式比對。 +- **`.refresh()`** 觸發背景重新拉取,同時仍然顯示先前(舊)的值(stale-while-revalidate)。 +- **`.is_refreshing()`** 當背景重新整理進行中時回傳 `true`,便於在舊資料旁邊顯示載入指示器。 + +`LinkError` 區分了應用層錯誤(`LinkError::Resolve`)與基礎設施錯誤(`LinkError::Internal`)。 + +如果多個元件並發請求相同的 `(T, Input)`,它們會自動共用同一個進行中的請求。 + +#### 伺服器端設定 + +```rust ,ignore +use yew_link::{Resolver, axum::linked_state_handler}; + +let resolver = Arc::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +let app = axum::Router::new() + .route("/api/link", axum::routing::post(linked_state_handler)) + .with_state(resolver); +``` + +上面的範例使用了 `axum` 特性。`yew-link` 也提供一個 `actix` 特性,將相同的 handler 暴露在 `yew_link::actix::linked_state_handler` 之下: + +```rust ,ignore +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +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 +``` + +<details> +<summary>為什麼 `actix-web` 被鎖定在 4.12,以及如何為其他伺服器框架實作 handler</summary> + +`actix` 特性被鎖定在 `actix-web 4.12.x`,因為 Yew 的 MSRV 為 `1.85`,而 `actix-web 4.13` 及更新的版本要求 `rustc 1.88`。這個版本約束僅存在於 `yew-link` 內部,目的是讓 workspace 的其他部分仍能在受支援的工具鏈上建構;它不會影響你在自己應用程式的其他地方使用哪個版本的 `actix-web`。 + +如果你需要更新版本的 `actix-web`(或其他任何 web 框架),並且不想啟用內建的特性,這個 handler 已經足夠小,完全可以自己內聯實作。你需要用到的全部公開 API 只有 `Resolver::resolve_request` 以及表示傳輸格式的 `LinkRequest` 型別: + +```rust +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data<Resolver>, + Json(req): Json<LinkRequest>, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +同樣的寫法適用於 `axum`、`warp`、`rocket`,或任何能夠將 JSON 反序列化為 `LinkRequest`、呼叫一個 async 函式並將結果序列化為 JSON 回應的框架。使用 `serde_json::json!` 建構回應的傳輸格式,可以避免讓 yew-link 內部的 `LinkResponse` 型別出現在你的依賴範圍中。 + +</details> + +#### 元件中的使用 + +```rust ,ignore +use yew_link::{use_linked_state, LinkProvider}; + +#[component] +fn PostPage(props: &PostPageProps) -> HtmlResult { + let post = use_linked_state::<Post>(props.id)?.data(); + Ok(html! { <h1>{ &post.title }</h1> }) +} +``` + +在 SSR 期間,狀態會透過 `Resolver` 在伺服器端本地解析,並透過 `use_prepared_state` 嵌入到 HTML 中。水合時客戶端會直接讀取嵌入的狀態,無需任何網路請求。在後續的客戶端導覽中,該鉤子會自動從 `LinkProvider` 的 endpoint URL 取得資料。 + +可以參考 [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) 與 [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) 範例,查看完整可運作的演示。 + ## 渲染 `<head>` 標籤 SSR 中的一個常見需求是渲染動態 `<head>` 內容(例如 `<title>`、`<meta>`),使爬蟲和社群預覽在首次載入時能看到正確的中繼資料。 `ServerRenderer` 只渲染元件樹(通常對應文件的 body 部分),無法存取 `<head>`。因此,head 標籤必須**在伺服器端、Yew 之外**產生,並在傳送給客戶端之前拼接到 HTML 範本中。 -[`ssr_router` 範例](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) 展示了這個模式:伺服器從請求 URL 識別路由,產生適當的 `<title>` 和 `<meta>` 標籤,並將它們注入到 Trunk 產生的 `index.html` 的 `</head>` 之前。 +[`axum_ssr_router` 範例](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) 展示了這個模式:伺服器從請求 URL 識別路由,產生適當的 `<title>` 和 `<meta>` 標籤,並將它們注入到 Trunk 產生的 `index.html` 的 `</head>` 之前。 :::info @@ -138,7 +287,8 @@ fn main() { ``` 範例: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -範例: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +範例: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +範例: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## 單線程模式 From 81b3e6e5f32cce22c369eb271f69eae537297e18 Mon Sep 17 00:00:00 2001 From: Matt Yan <syan4@ualberta.ca> Date: Tue, 7 Apr 2026 23:02:12 +0900 Subject: [PATCH 14/16] test(website): test native snippets too --- .github/workflows/test-website.yml | 18 ++++ Cargo.lock | 1 + tools/website-test/Cargo.toml | 11 +- tools/website-test/build.rs | 2 +- .../advanced-topics/server-side-rendering.mdx | 100 ++++++++++++++++-- website/docs/concepts/html/fragments.mdx | 2 +- .../advanced-topics/server-side-rendering.mdx | 100 ++++++++++++++++-- .../advanced-topics/server-side-rendering.mdx | 100 ++++++++++++++++-- .../advanced-topics/server-side-rendering.mdx | 100 ++++++++++++++++-- 9 files changed, 388 insertions(+), 46 deletions(-) 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/Cargo.lock b/Cargo.lock index a4793033d7b..43a88a6bf72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4766,6 +4766,7 @@ name = "website-test" version = "0.1.0" dependencies = [ "actix-web", + "axum", "derive_more", "glob", "gloo", diff --git a/tools/website-test/Cargo.toml b/tools/website-test/Cargo.toml index cefcb6bc087..193a921bec9 100644 --- a/tools/website-test/Cargo.toml +++ b/tools/website-test/Cargo.toml @@ -10,22 +10,25 @@ rust-version.workspace = true yew-agent = { path = "../../packages/yew-agent/" } [dev-dependencies] -actix-web = { workspace = true } derive_more = { version = "2.1", features = ["from"] } gloo.workspace = true gloo-net = "0.7" js-sys.workspace = true serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } 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-link = { path = "../../packages/yew-link/", features = ["actix"] } 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 e72e6151905..0189f1e2d4a 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -148,7 +148,10 @@ The `yew-link` crate provides a higher-level abstraction that unifies all three 3. **Wrap** your app in `<LinkProvider>`. 4. **Call** `use_linked_state::<MyData>(input)` in any component. -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -171,7 +174,20 @@ The macro generates the `LinkedState` and (server-only) `LinkedStateResolve` tra If `resolve` can fail, declare `type Error`: -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -199,7 +215,27 @@ Multiple components requesting the same `(T, Input)` concurrently share a single #### Server setup -```rust ,ignore +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# fn main() { +# let db_pool = DbPool; use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( @@ -207,15 +243,37 @@ let resolver = Arc::new( .register_linked::<Post>(db_pool.clone()) ); -let app = axum::Router::new() - .route("/api/link", axum::routing::post(linked_state_handler)) - .with_state(resolver); +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } ``` The example above uses the `axum` feature. `yew-link` also ships an `actix` feature that exposes the same handler under `yew_link::actix::linked_state_handler`: -```rust ,ignore +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; use actix_web::{App, HttpServer, web::{Data, post}}; use yew_link::{Resolver, actix::linked_state_handler}; @@ -232,6 +290,8 @@ HttpServer::new(move || { .bind(("0.0.0.0", 8080))? .run() .await +# } +# fn main() {} ``` <details> @@ -248,7 +308,7 @@ to enable the bundled feature, the handler is small enough to inline yourself. The only public surface you need is `Resolver::resolve_request` and the wire-format type `LinkRequest`: -```rust +```rust ,ignore-wasm32 use actix_web::HttpResponse; use actix_web::web::{Data, Json}; use serde_json::json; @@ -275,7 +335,27 @@ surface. #### Component usage -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] use yew_link::{use_linked_state, LinkProvider}; #[component] @@ -356,7 +436,7 @@ until `rendered()` method is called. ## Example -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; diff --git a/website/docs/concepts/html/fragments.mdx b/website/docs/concepts/html/fragments.mdx index 21a6ca3388a..2612a041f8e 100644 --- a/website/docs/concepts/html/fragments.mdx +++ b/website/docs/concepts/html/fragments.mdx @@ -27,7 +27,7 @@ html! { <TabItem value="Invalid" label="Invalid"> -```rust, compile_fail +```rust ,compile_fail use yew::prelude::*; // error: only one root html element allowed diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index 3df36e9cfde..0e78401625c 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -104,7 +104,10 @@ Yew は、サーバー側で計算した状態をクライアントへ運ぶた 3. アプリを `<LinkProvider>` で **包みます**。 4. 任意のコンポーネントから `use_linked_state::<MyData>(input)` を **呼び出します**。 -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -127,7 +130,20 @@ impl LinkedState for Post { `resolve` が失敗する可能性がある場合は、`type Error` を宣言します: -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -155,7 +171,27 @@ impl LinkedState for Post { #### サーバー側の設定 -```rust ,ignore +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# fn main() { +# let db_pool = DbPool; use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( @@ -163,14 +199,36 @@ let resolver = Arc::new( .register_linked::<Post>(db_pool.clone()) ); -let app = axum::Router::new() - .route("/api/link", axum::routing::post(linked_state_handler)) - .with_state(resolver); +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } ``` 上の例では `axum` 機能を使っています。`yew-link` は `actix` 機能も提供しており、同じハンドラを `yew_link::actix::linked_state_handler` 経由で公開します: -```rust ,ignore +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; use actix_web::{App, HttpServer, web::{Data, post}}; use yew_link::{Resolver, actix::linked_state_handler}; @@ -187,6 +245,8 @@ HttpServer::new(move || { .bind(("0.0.0.0", 8080))? .run() .await +# } +# fn main() {} ``` <details> @@ -196,7 +256,7 @@ HttpServer::new(move || { より新しい `actix-web`(あるいは他の web フレームワーク)が必要で、同梱の機能を有効にしたくない場合、ハンドラ自体は十分に小さいので自分でインライン実装できます。必要となる公開 API は `Resolver::resolve_request` と、ワイヤーフォーマットの型 `LinkRequest` だけです: -```rust +```rust ,ignore-wasm32 use actix_web::HttpResponse; use actix_web::web::{Data, Json}; use serde_json::json; @@ -219,7 +279,27 @@ pub async fn linked_state_handler( #### コンポーネント内での利用 -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] use yew_link::{use_linked_state, LinkProvider}; #[component] @@ -269,7 +349,7 @@ SSR出力(静的HTML)をブラウザが初期レンダリングした後、 ## 例 -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index 4539bb588e2..7f4f89ad759 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -104,7 +104,10 @@ Yew 提供了两个低层钩子,用于将服务端计算出的状态传递到 3. 用 `<LinkProvider>` **包裹** 你的应用。 4. 在任意组件中 **调用** `use_linked_state::<MyData>(input)`。 -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -127,7 +130,20 @@ impl LinkedState for Post { 如果 `resolve` 可能失败,可以声明 `type Error`: -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -155,7 +171,27 @@ impl LinkedState for Post { #### 服务端设置 -```rust ,ignore +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# fn main() { +# let db_pool = DbPool; use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( @@ -163,14 +199,36 @@ let resolver = Arc::new( .register_linked::<Post>(db_pool.clone()) ); -let app = axum::Router::new() - .route("/api/link", axum::routing::post(linked_state_handler)) - .with_state(resolver); +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } ``` 上面的例子使用了 `axum` 特性。`yew-link` 也提供了一个 `actix` 特性,将相同的 handler 暴露在 `yew_link::actix::linked_state_handler` 之下: -```rust ,ignore +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; use actix_web::{App, HttpServer, web::{Data, post}}; use yew_link::{Resolver, actix::linked_state_handler}; @@ -187,6 +245,8 @@ HttpServer::new(move || { .bind(("0.0.0.0", 8080))? .run() .await +# } +# fn main() {} ``` <details> @@ -196,7 +256,7 @@ HttpServer::new(move || { 如果你需要更新版本的 `actix-web`(或者任何其他 web 框架),并且不想启用内置的特性,这个 handler 足够小,完全可以自己内联实现。你需要用到的全部公开 API 只有 `Resolver::resolve_request` 以及表示传输格式的 `LinkRequest` 类型: -```rust +```rust ,ignore-wasm32 use actix_web::HttpResponse; use actix_web::web::{Data, Json}; use serde_json::json; @@ -219,7 +279,27 @@ pub async fn linked_state_handler( #### 组件中的使用 -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] use yew_link::{use_linked_state, LinkProvider}; #[component] @@ -269,7 +349,7 @@ SSR 中的一个常见需求是渲染动态 `<head>` 内容(例如 `<title>` ## 示例 -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; diff --git a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index 063c018b7bc..d1e1a7a7f27 100644 --- a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -104,7 +104,10 @@ Yew 提供了兩個低階鉤子,用於將伺服器端計算出的狀態傳遞 3. 用 `<LinkProvider>` **包裹** 你的應用程式。 4. 在任意元件中 **呼叫** `use_linked_state::<MyData>(input)`。 -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -127,7 +130,20 @@ impl LinkedState for Post { 如果 `resolve` 可能失敗,可以宣告 `type Error`: -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -155,7 +171,27 @@ impl LinkedState for Post { #### 伺服器端設定 -```rust ,ignore +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# fn main() { +# let db_pool = DbPool; use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( @@ -163,14 +199,36 @@ let resolver = Arc::new( .register_linked::<Post>(db_pool.clone()) ); -let app = axum::Router::new() - .route("/api/link", axum::routing::post(linked_state_handler)) - .with_state(resolver); +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } ``` 上面的範例使用了 `axum` 特性。`yew-link` 也提供一個 `actix` 特性,將相同的 handler 暴露在 `yew_link::actix::linked_state_handler` 之下: -```rust ,ignore +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; use actix_web::{App, HttpServer, web::{Data, post}}; use yew_link::{Resolver, actix::linked_state_handler}; @@ -187,6 +245,8 @@ HttpServer::new(move || { .bind(("0.0.0.0", 8080))? .run() .await +# } +# fn main() {} ``` <details> @@ -196,7 +256,7 @@ HttpServer::new(move || { 如果你需要更新版本的 `actix-web`(或其他任何 web 框架),並且不想啟用內建的特性,這個 handler 已經足夠小,完全可以自己內聯實作。你需要用到的全部公開 API 只有 `Resolver::resolve_request` 以及表示傳輸格式的 `LinkRequest` 型別: -```rust +```rust ,ignore-wasm32 use actix_web::HttpResponse; use actix_web::web::{Data, Json}; use serde_json::json; @@ -219,7 +279,27 @@ pub async fn linked_state_handler( #### 元件中的使用 -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[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 +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] use yew_link::{use_linked_state, LinkProvider}; #[component] @@ -269,7 +349,7 @@ SSR 中的一個常見需求是渲染動態 `<head>` 內容(例如 `<title>` ## 範例 -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; From 93cbfcea1af8b10a485a99f9a2eaea60e5643554 Mon Sep 17 00:00:00 2001 From: Matt Yan <syan4@ualberta.ca> Date: Wed, 8 Apr 2026 00:09:35 +0900 Subject: [PATCH 15/16] fix(yew-link): test snippets --- packages/yew-link/src/services.rs | 89 +++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/packages/yew-link/src/services.rs b/packages/yew-link/src/services.rs index 99349120272..a06f15706ee 100644 --- a/packages/yew-link/src/services.rs +++ b/packages/yew-link/src/services.rs @@ -11,15 +11,38 @@ pub mod axum { /// Axum handler that resolves [`LinkRequest`]s. /// - /// ```ignore - /// let resolver = Arc::new( - /// Resolver::new() - /// .register::<Post>(|id| async move { db::get_post(id).await }) - /// ); + /// ``` + /// 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<Post, Never> { + /// Ok(Post { + /// title: String::new(), + /// }) + /// } + /// + /// let resolver = Arc::new(Resolver::new().register::<Post, _, _>(|id| get_post(id))); /// - /// let app = axum::Router::new() - /// .route("/api/link", axum::routing::post(linked_state_handler)) - /// .with_state(resolver); + /// 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<Arc<Resolver>>, @@ -53,20 +76,44 @@ pub mod actix { /// Actix handler that resolves [`LinkRequest`]s. /// - /// ```ignore - /// let resolver = Data::new( - /// Resolver::new() - /// .register::<Post>(|id| async move { db::get_post(id).await }) - /// ); + /// ```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<Post, Never> { + /// Ok(Post { + /// title: String::new(), + /// }) + /// } + /// + /// #[actix_web::main] + /// async fn main() -> std::io::Result<()> { + /// let resolver = Data::new(Resolver::new().register::<Post, _, _>(|id| get_post(id))); /// - /// HttpServer::new(move || { - /// App::new() - /// .route("/api/link", post().to(linked_state_handler)) - /// .app_data(resolver) - /// }) - /// .bind(("0.0.0.0", 8080))? - /// .run() - /// .await + /// 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<Resolver>, From 082854cedce320335d7001ba2ac3fe059229667a Mon Sep 17 00:00:00 2001 From: Matt Yan <syan4@ualberta.ca> Date: Wed, 8 Apr 2026 00:25:08 +0900 Subject: [PATCH 16/16] refactor: extract common ssr example testing code --- Cargo.lock | 1 + examples/actix_ssr_router/tests/e2e.rs | 176 +--------------------- examples/axum_ssr_router/tests/e2e.rs | 176 +--------------------- tools/ssr-e2e-harness/Cargo.toml | 1 + tools/ssr-e2e-harness/src/lib.rs | 201 +++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 344 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43a88a6bf72..933ae34a456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3745,6 +3745,7 @@ dependencies = [ "gloo", "wasm-bindgen", "web-sys", + "yew", ] [[package]] diff --git a/examples/actix_ssr_router/tests/e2e.rs b/examples/actix_ssr_router/tests/e2e.rs index 0b5b18b38e0..24383f585db 100644 --- a/examples/actix_ssr_router/tests/e2e.rs +++ b/examples/actix_ssr_router/tests/e2e.rs @@ -1,12 +1,9 @@ #![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] use actix_ssr_router::{App, AppProps, LINK_ENDPOINT}; -use gloo::utils::document; use ssr_e2e_harness::{ - clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, - wait_for, + assert_hydrate_home, assert_ssr_hydration_and_client_navigation, output_element, }; -use wasm_bindgen::JsCast; use wasm_bindgen_test::*; use yew::Renderer; @@ -14,186 +11,21 @@ wasm_bindgen_test_configure!(run_in_browser); const SERVER_BASE: &str = "http://127.0.0.1:8080"; -fn endpoint() -> String { - format!("{SERVER_BASE}{LINK_ENDPOINT}") -} - fn make_renderer() -> Renderer<App> { Renderer::<App>::with_root_and_props( output_element(), AppProps { - endpoint: endpoint().into(), + endpoint: format!("{SERVER_BASE}{LINK_ENDPOINT}").into(), }, ) } -fn get_title_text() -> Option<String> { - document() - .query_selector("h1.title") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) -} - -fn post_body_text() -> String { - output_element() - .query_selector(".section.container") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) - .unwrap_or_default() -} - -fn extract_text_from_html(html: &str, selector: &str) -> Option<String> { - let container = document().create_element("div").unwrap(); - container.set_inner_html(html); - container - .query_selector(selector) - .ok() - .flatten() - .and_then(|el| el.text_content()) -} - #[wasm_bindgen_test] async fn ssr_hydration_and_client_navigation() { - // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link -- - - let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await; - let ssr_title = extract_text_from_html(&ssr_html, "h1.title") - .expect("SSR HTML for /posts/0 should contain h1.title"); - let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); - - clear_resource_timings(); - - output_element().set_inner_html(&ssr_html); - ssr_e2e_harness::push_route("/posts/0"); - let app = make_renderer().hydrate(); - - wait_for( - || { - let html = output_element().inner_html(); - html.contains("<h1 class=\"title\">") && !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; + assert_ssr_hydration_and_client_navigation(make_renderer, SERVER_BASE, LINK_ENDPOINT).await; } #[wasm_bindgen_test] async fn hydrate_home() { - setup_ssr_page(SERVER_BASE, "/").await; - let app = make_renderer().hydrate(); - - wait_for( - || output_element().inner_html().contains("Welcome"), - 5000, - "home page content after hydration", - ) - .await; - - app.destroy(); - yew::scheduler::flush().await; + assert_hydrate_home(make_renderer, SERVER_BASE).await; } diff --git a/examples/axum_ssr_router/tests/e2e.rs b/examples/axum_ssr_router/tests/e2e.rs index 9bf3e10a8c3..29b5d731867 100644 --- a/examples/axum_ssr_router/tests/e2e.rs +++ b/examples/axum_ssr_router/tests/e2e.rs @@ -1,12 +1,9 @@ #![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] use axum_ssr_router::{App, AppProps, LINK_ENDPOINT}; -use gloo::utils::document; use ssr_e2e_harness::{ - clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, - wait_for, + assert_hydrate_home, assert_ssr_hydration_and_client_navigation, output_element, }; -use wasm_bindgen::JsCast; use wasm_bindgen_test::*; use yew::Renderer; @@ -14,186 +11,21 @@ wasm_bindgen_test_configure!(run_in_browser); const SERVER_BASE: &str = "http://127.0.0.1:8080"; -fn endpoint() -> String { - format!("{SERVER_BASE}{LINK_ENDPOINT}") -} - fn make_renderer() -> Renderer<App> { Renderer::<App>::with_root_and_props( output_element(), AppProps { - endpoint: endpoint().into(), + endpoint: format!("{SERVER_BASE}{LINK_ENDPOINT}").into(), }, ) } -fn get_title_text() -> Option<String> { - document() - .query_selector("h1.title") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) -} - -fn post_body_text() -> String { - output_element() - .query_selector(".section.container") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) - .unwrap_or_default() -} - -fn extract_text_from_html(html: &str, selector: &str) -> Option<String> { - let container = document().create_element("div").unwrap(); - container.set_inner_html(html); - container - .query_selector(selector) - .ok() - .flatten() - .and_then(|el| el.text_content()) -} - #[wasm_bindgen_test] async fn ssr_hydration_and_client_navigation() { - // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link -- - - let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await; - let ssr_title = extract_text_from_html(&ssr_html, "h1.title") - .expect("SSR HTML for /posts/0 should contain h1.title"); - let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); - - clear_resource_timings(); - - output_element().set_inner_html(&ssr_html); - ssr_e2e_harness::push_route("/posts/0"); - let app = make_renderer().hydrate(); - - wait_for( - || { - let html = output_element().inner_html(); - html.contains("<h1 class=\"title\">") && !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; + assert_ssr_hydration_and_client_navigation(make_renderer, SERVER_BASE, LINK_ENDPOINT).await; } #[wasm_bindgen_test] async fn hydrate_home() { - setup_ssr_page(SERVER_BASE, "/").await; - let app = make_renderer().hydrate(); - - wait_for( - || output_element().inner_html().contains("Welcome"), - 5000, - "home page content after hydration", - ) - .await; - - app.destroy(); - yew::scheduler::flush().await; + assert_hydrate_home(make_renderer, SERVER_BASE).await; } diff --git a/tools/ssr-e2e-harness/Cargo.toml b/tools/ssr-e2e-harness/Cargo.toml index 5318504a890..3c1ec539385 100644 --- a/tools/ssr-e2e-harness/Cargo.toml +++ b/tools/ssr-e2e-harness/Cargo.toml @@ -8,3 +8,4 @@ rust-version.workspace = true gloo = { workspace = true, features = ["futures"] } 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 59f4dda5146..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 `<div id="output">` element used by wasm-bindgen-test as the /// test output container. @@ -82,3 +84,202 @@ pub fn resource_request_count(needle: &str) -> u32 { pub fn clear_resource_timings() { performance().clear_resource_timings(); } + +/// Returns the text content of the first `<h1 class="title">` in the document. +pub fn get_title_text() -> Option<String> { + 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<String> { + 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<COMP>` 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<COMP>( + make_renderer: impl FnOnce() -> Renderer<COMP>, + 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("<h1 class=\"title\">") && !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<COMP>( + make_renderer: impl FnOnce() -> Renderer<COMP>, + 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; +}