diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index f25d21553c7..f92fb776be2 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -50,7 +50,7 @@ jobs: TARGET_FLAGS: ${{ case(matrix.target == 'wasm32', '--target wasm32-unknown-unknown', null) }} run: >- cargo hack clippy - -p yew -p yew-agent -p yew-router + -p yew -p yew-agent -p yew-router -p yew-link --feature-powerset --no-dev-deps --keep-going $RELEASE_FLAG diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml index 89f6a20a491..6f2f9fb8dc9 100644 --- a/.github/workflows/main-checks.yml +++ b/.github/workflows/main-checks.yml @@ -144,7 +144,7 @@ jobs: RUSTFLAGS: ${{ matrix.toolchain == 'nightly' && '--cfg nightly_yew' || '' }} run: | if [[ "${{ matrix.toolchain }}" == "1.85.0" ]]; then - cargo test --all-targets -p yew-agent -p yew-agent-macro -p yew-router + cargo test --all-targets -p yew-agent -p yew-agent-macro -p yew-router -p yew-link -p yew-link-macro else ls packages | grep -v "^yew$" | xargs -I {} cargo test --all-targets -p {} fi @@ -301,9 +301,12 @@ jobs: fail-fast: false matrix: include: - - example: ssr_router + - example: axum_ssr_router server_bin: ssr_router_server - trunk_dir: examples/ssr_router + trunk_dir: examples/axum_ssr_router + - example: actix_ssr_router + server_bin: ssr_router_server + trunk_dir: examples/actix_ssr_router - example: simple_ssr server_bin: simple_ssr_server trunk_dir: examples/simple_ssr diff --git a/.github/workflows/test-website.yml b/.github/workflows/test-website.yml index b3c9c146fe3..6c2593979ab 100644 --- a/.github/workflows/test-website.yml +++ b/.github/workflows/test-website.yml @@ -44,3 +44,21 @@ jobs: - name: Run website code snippet tests run: cargo test -p website-test --target wasm32-unknown-unknown + + website_tests_native: + name: Tests Website Snippets (native) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/master' }} + + - name: Run website code snippet tests + run: cargo test -p website-test diff --git a/.gitignore b/.gitignore index bec5f8c71aa..d3244a16012 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ *.iml /.idea/ /.vscode/ +.nvim.lua diff --git a/Cargo.lock b/Cargo.lock index 26fef1c45bb..a0755cc49ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,258 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash 0.1.5", + "futures-core", + "h2 0.3.27", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.3", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "actix_ssr_router" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-files", + "actix-web", + "bytes", + "clap", + "env_logger", + "function_router", + "futures 0.3.32", + "getrandom 0.4.2", + "gloo", + "jemallocator", + "rand 0.10.0", + "serde", + "serde_json", + "ssr-e2e-harness", + "tokio", + "tracing-subscriber", + "tracing-web", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", + "yew-link", + "yew-router", +] + [[package]] name = "adler2" version = "2.0.1" @@ -17,6 +269,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -99,7 +372,7 @@ version = "0.0.1" dependencies = [ "chrono", "futures 0.3.32", - "gloo-net", + "gloo-net 0.7.0", "yew", ] @@ -167,7 +440,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -198,7 +471,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -209,6 +482,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum_ssr_router" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "env_logger", + "function_router", + "futures 0.3.32", + "getrandom 0.4.2", + "gloo", + "hyper", + "hyper-util", + "jemallocator", + "rand 0.10.0", + "serde", + "serde_json", + "ssr-e2e-harness", + "tokio", + "tower", + "tower-http", + "tracing-subscriber", + "tracing-web", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", + "yew-link", + "yew-router", +] + [[package]] name = "base64" version = "0.22.1" @@ -317,6 +622,27 @@ dependencies = [ "yew", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "build-examples" version = "0.1.0" @@ -363,6 +689,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + [[package]] name = "cast" version = "0.3.0" @@ -586,6 +921,26 @@ dependencies = [ "yew-agent", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -762,10 +1117,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -983,6 +1340,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1238,7 +1601,7 @@ dependencies = [ "gloo-events", "gloo-file", "gloo-history", - "gloo-net", + "gloo-net 0.7.0", "gloo-render", "gloo-storage", "gloo-timers", @@ -1309,6 +1672,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.4.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-net" version = "0.7.0" @@ -1319,7 +1703,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.3.0", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -1424,6 +1808,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1435,7 +1838,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1458,7 +1861,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1466,6 +1869,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "headers" @@ -1476,7 +1884,7 @@ dependencies = [ "base64", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -1488,7 +1896,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.4.0", ] [[package]] @@ -1526,6 +1934,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1543,7 +1962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1554,11 +1973,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "http-range-header" version = "0.4.2" @@ -1587,8 +2012,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", + "h2 0.4.13", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -1605,7 +2030,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -1625,14 +2050,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -1801,6 +2226,12 @@ dependencies = [ "yew", ] +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + [[package]] name = "implicit-clone" version = "0.6.0" @@ -2061,6 +2492,12 @@ dependencies = [ "yew", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -2115,9 +2552,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.27" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3884c0d1cee1f72141b54543005490614e7f96890d10c99a238a54cbd0e43d81" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "libc", @@ -2146,6 +2583,23 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.14" @@ -2161,6 +2615,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2228,6 +2691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2367,6 +2831,29 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "password_strength" version = "0.1.0" @@ -2620,7 +3107,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2658,7 +3145,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2760,6 +3247,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -2807,8 +3303,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", + "h2 0.4.13", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -3203,6 +3699,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3239,31 +3745,7 @@ dependencies = [ "gloo", "wasm-bindgen", "web-sys", -] - -[[package]] -name = "ssr_router" -version = "0.1.0" -dependencies = [ - "axum", - "clap", - "env_logger", - "function_router", - "futures 0.3.32", - "gloo", - "hyper", - "hyper-util", - "jemallocator", - "ssr-e2e-harness", - "tokio", - "tower", - "tower-http", - "tracing-subscriber", - "tracing-web", - "wasm-bindgen-futures", - "wasm-bindgen-test", "yew", - "yew-router", ] [[package]] @@ -3493,10 +3975,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3505,6 +3989,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "timer" version = "0.1.0" @@ -3571,9 +4065,10 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3721,7 +4216,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "http-range-header", @@ -3869,6 +4364,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-width" version = "0.2.2" @@ -3946,6 +4447,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "valuable" version = "0.1.1" @@ -3997,7 +4504,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -4259,12 +4766,15 @@ dependencies = [ name = "website-test" version = "0.1.0" dependencies = [ + "actix-web", + "axum", "derive_more", "glob", "gloo", - "gloo-net", + "gloo-net 0.7.0", "js-sys", "serde", + "serde_json", "tokio", "wasm-bindgen", "wasm-bindgen-futures", @@ -4273,6 +4783,7 @@ dependencies = [ "yew", "yew-agent", "yew-autoprops", + "yew-link", "yew-router", ] @@ -4755,6 +5266,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "yew-link" +version = "0.1.0" +dependencies = [ + "actix-web", + "axum", + "gloo-net 0.6.0", + "lru", + "serde", + "serde_json", + "wasm-bindgen-futures", + "yew", + "yew-link-macro", +] + +[[package]] +name = "yew-link-macro" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "yew-macro" version = "0.23.0" @@ -4934,6 +5469,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zxcvbn" version = "3.1.0" diff --git a/Cargo.toml b/Cargo.toml index ad7ec04e13c..7364b8c8638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,3 +66,6 @@ chrono = "0.4" thiserror = "2.0" bincode = { version = "2.0.0-rc.3", features = ["serde"] } reqwest = "0.13" +axum = "0.8" +# 4.13 requires rustc > 1.85 +actix-web = "<4.13" diff --git a/examples/README.md b/examples/README.md index 01c7d15d333..8053fc64743 100644 --- a/examples/README.md +++ b/examples/README.md @@ -58,7 +58,8 @@ As an example, check out the TodoMVC example here: ` support. | | [timer](timer) | [S] | Demonstrates the use of the interval and timeout services. | | [timer_functional](timer_functional) | [F] | Demonstrates the use of the interval and timeout services using function components | diff --git a/examples/actix_ssr_router/Cargo.toml b/examples/actix_ssr_router/Cargo.toml new file mode 100644 index 00000000000..6cf2e181194 --- /dev/null +++ b/examples/actix_ssr_router/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "actix_ssr_router" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true + +[[bin]] +name = "ssr_router_hydrate" +required-features = ["hydration"] + +[[bin]] +name = "ssr_router_server" +required-features = ["ssr"] + +[dependencies] +yew = { path = "../../packages/yew" } +yew-link = { path = "../../packages/yew-link" } +yew-router = { path = "../../packages/yew-router" } +function_router = { path = "../function_router" } +rand = { workspace = true } +getrandom = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +futures = { workspace = true, features = ["std"] } +bytes = "1.11.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures.workspace = true +tracing-web.workspace = true +tracing-subscriber.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] } +env_logger = "0.11" +clap = { workspace = true } +actix-web = { workspace = true } +actix-cors = "0.7" +actix-files = "0.6" + +[target.'cfg(unix)'.dependencies] +jemallocator = "0.5" + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test.workspace = true +wasm-bindgen.workspace = true +web-sys = { workspace = true } +gloo = { workspace = true } +ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" } + +[dev-dependencies] +yew = { path = "../../packages/yew", features = ["hydration", "test"] } +yew-link = { path = "../../packages/yew-link", features = ["hydration"] } + +[features] +ssr = ["yew/ssr", "yew-link/ssr", "yew-link/actix"] +hydration = ["yew/hydration", "yew-link/hydration"] diff --git a/examples/actix_ssr_router/README.md b/examples/actix_ssr_router/README.md new file mode 100644 index 00000000000..0621f345acb --- /dev/null +++ b/examples/actix_ssr_router/README.md @@ -0,0 +1,14 @@ +# SSR Router Example + +This example is the same as the `axum_ssr_router`, except the +server side is served with `actix_web` instead of axum. + +# How to run this example + +1. Build Hydration Bundle + +`trunk build` + +2. Run the server + +`cargo run --features=ssr --bin ssr_router_server -- --dir dist` diff --git a/examples/ssr_router/index.html b/examples/actix_ssr_router/index.html similarity index 100% rename from examples/ssr_router/index.html rename to examples/actix_ssr_router/index.html diff --git a/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs b/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs new file mode 100644 index 00000000000..38761324508 --- /dev/null +++ b/examples/actix_ssr_router/src/bin/ssr_router_hydrate.rs @@ -0,0 +1,18 @@ +use actix_ssr_router::{App, AppProps, LINK_ENDPOINT}; + +fn main() { + #[cfg(target_arch = "wasm32")] + { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) // Only partially supported across browsers + .without_time() // std::time is not available in browsers + .with_writer(tracing_web::MakeWebConsoleWriter::new()) + .with_filter(tracing_subscriber::filter::LevelFilter::TRACE); + use tracing_subscriber::prelude::*; + tracing_subscriber::registry().with(fmt_layer).init(); + } + yew::Renderer::::with_props(AppProps { + endpoint: LINK_ENDPOINT.into(), + }) + .hydrate(); +} diff --git a/examples/actix_ssr_router/src/bin/ssr_router_server.rs b/examples/actix_ssr_router/src/bin/ssr_router_server.rs new file mode 100644 index 00000000000..43569b7831f --- /dev/null +++ b/examples/actix_ssr_router/src/bin/ssr_router_server.rs @@ -0,0 +1,126 @@ +use std::collections::HashMap; +use std::io::Result as IoResult; +use std::path::PathBuf; + +use actix_cors::Cors; +use actix_files::Files; +use actix_ssr_router::{ + LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, +}; +use actix_web::http::Uri; +use actix_web::web::{Data, Query, get, post}; +use actix_web::{App, Error, HttpResponse, HttpServer}; +use bytes::Bytes; +use clap::Parser; +use function_router::{Route, route_meta}; +use futures::stream::{self, StreamExt}; +use yew_link::actix::linked_state_handler; +use yew_link::{Resolver, ResolverProp}; +use yew_router::prelude::Routable; + +// We use jemalloc as it produces better performance. +#[cfg(unix)] +#[global_allocator] +static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; + +/// A basic example +#[derive(Parser, Debug)] +struct Opt { + /// the "dist" created by trunk directory to be served for hydration. + #[clap(short, long)] + dir: PathBuf, +} + +fn head_tags_for(path: &str) -> String { + let route = Route::recognize(path).unwrap_or(Route::NotFound); + let (title, description) = route_meta(&route); + format!( + "{title} | Yew SSR Router" + ) +} + +#[derive(Clone)] +struct AppState { + index_html_before: String, + index_html_after: String, + resolver: ResolverProp, +} + +async fn render( + url: Uri, + Query(queries): Query>, + state: Data, +) -> HttpResponse { + let state = state.into_inner(); + + let path = url.path().to_owned(); + + // Inject route-specific tags before , outside of Yew rendering. + let before = state + .index_html_before + .replace("", &format!("{}", head_tags_for(&path))); + let resolver = state.resolver.clone(); + + let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps { + url: path.into(), + queries, + resolver, + }); + + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .streaming( + stream::once(async move { Bytes::from(before) }) + .chain(renderer.render_stream().map(Bytes::from)) + .chain(stream::once(async move { + Bytes::from(state.index_html_after.clone()) + })) + .map(Ok::), + ) +} + +#[actix_web::main] +async fn main() -> IoResult<()> { + env_logger::init(); + let opts = Opt::parse(); + + let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html")) + .await + .expect("failed to read index.html"); + + let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap(); + let mut index_html_before = index_html_before.to_owned(); + index_html_before.push_str(""); + let index_html_after = index_html_after.to_owned(); + + let resolver_prop: ResolverProp = Resolver::new() + .register_linked::(()) + .register_linked::(()) + .register_linked::(()) + .into(); + let resolver_data = Data::from(resolver_prop.0.clone()); + + let app_state = Data::new(AppState { + index_html_before, + index_html_after, + resolver: resolver_prop, + }); + + let dir = opts.dir.clone(); + HttpServer::new(move || { + App::new() + .wrap(Cors::permissive()) + .app_data(app_state.clone()) + .app_data(resolver_data.clone()) + .route(LINK_ENDPOINT, post().to(linked_state_handler)) + .service( + Files::new("/", &dir) + .index_file("__no_index__") + .default_handler(get().to(render)), + ) + }) + .bind(("0.0.0.0", 8080))? + .run() + .await +} diff --git a/examples/actix_ssr_router/src/lib.rs b/examples/actix_ssr_router/src/lib.rs new file mode 100644 index 00000000000..913510c7dfc --- /dev/null +++ b/examples/actix_ssr_router/src/lib.rs @@ -0,0 +1,467 @@ +use std::collections::HashMap; + +#[cfg(not(target_arch = "wasm32"))] +use function_router::Generated; +use function_router::content; +use rand::RngExt; +use serde::{Deserialize, Serialize}; +use yew::prelude::*; +use yew_link::{LinkProvider, ResolverProp, linked_state, use_linked_state}; +use yew_router::prelude::*; + +pub const LINK_ENDPOINT: &str = "/api/link"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPost(pub content::Post); + +#[linked_state] +impl LinkedState for LinkedPost { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Post::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedAuthor(pub content::Author); + +#[linked_state] +impl LinkedState for LinkedAuthor { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Author::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPostMeta(pub content::PostMeta); + +#[linked_state] +impl LinkedState for LinkedPostMeta { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::PostMeta::generate_from_seed(*seed)) + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct PostProps { + pub id: u32, +} + +#[component] +pub fn PostPage(props: &PostProps) -> HtmlResult { + let post = use_linked_state::(props.id)?.data(); + Ok(render_post(&post.0)) +} + +fn render_post(post: &content::Post) -> Html { + use content::PostPart; + + let render_quote = |quote: &content::Quote| { + html! { +
+
+

+ 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; + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect::>() + }); + + let on_complete = { + let seeds = seeds.clone(); + Callback::from(move |_| { + use rand::distr; + seeds.set( + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect(), + ); + }) + }; + + html! { +
+
+
+
+

{ "Authors" }

+

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

+
+
+
+

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

+
+
+ for seed in seeds.iter().copied() { +
+
+
{"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..24383f585db --- /dev/null +++ b/examples/actix_ssr_router/tests/e2e.rs @@ -0,0 +1,31 @@ +#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + +use actix_ssr_router::{App, AppProps, LINK_ENDPOINT}; +use ssr_e2e_harness::{ + assert_hydrate_home, assert_ssr_hydration_and_client_navigation, output_element, +}; +use wasm_bindgen_test::*; +use yew::Renderer; + +wasm_bindgen_test_configure!(run_in_browser); + +const SERVER_BASE: &str = "http://127.0.0.1:8080"; + +fn make_renderer() -> Renderer { + Renderer::::with_root_and_props( + output_element(), + AppProps { + endpoint: format!("{SERVER_BASE}{LINK_ENDPOINT}").into(), + }, + ) +} + +#[wasm_bindgen_test] +async fn ssr_hydration_and_client_navigation() { + assert_ssr_hydration_and_client_navigation(make_renderer, SERVER_BASE, LINK_ENDPOINT).await; +} + +#[wasm_bindgen_test] +async fn hydrate_home() { + assert_hydrate_home(make_renderer, SERVER_BASE).await; +} diff --git a/examples/ssr_router/Cargo.toml b/examples/axum_ssr_router/Cargo.toml similarity index 68% rename from examples/ssr_router/Cargo.toml rename to examples/axum_ssr_router/Cargo.toml index baf60b83460..a164375d515 100644 --- a/examples/ssr_router/Cargo.toml +++ b/examples/axum_ssr_router/Cargo.toml @@ -1,11 +1,9 @@ [package] -name = "ssr_router" +name = "axum_ssr_router" version = "0.1.0" edition = "2024" rust-version.workspace = true -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [[bin]] name = "ssr_router_hydrate" required-features = ["hydration"] @@ -16,7 +14,13 @@ required-features = ["ssr"] [dependencies] yew = { path = "../../packages/yew" } +yew-link = { path = "../../packages/yew-link" } +yew-router = { path = "../../packages/yew-router" } function_router = { path = "../function_router" } +rand = { workspace = true } +getrandom = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } futures = { workspace = true, features = ["std"] } hyper-util = "0.1.20" @@ -26,9 +30,8 @@ tracing-web.workspace = true tracing-subscriber.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -yew-router = { path = "../../packages/yew-router" } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "fs"] } -axum = "0.8" +axum = { workspace = true } tower = { version = "0.5", features = ["make"] } tower-http = { version = "0.6", features = ["fs", "cors"] } env_logger = "0.11" @@ -40,12 +43,15 @@ jemallocator = "0.5" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test.workspace = true +wasm-bindgen.workspace = true +web-sys = { workspace = true } gloo = { workspace = true } ssr-e2e-harness = { path = "../../tools/ssr-e2e-harness" } [dev-dependencies] -yew = { path = "../../packages/yew", features = ["hydration"] } +yew = { path = "../../packages/yew", features = ["hydration", "test"] } +yew-link = { path = "../../packages/yew-link", features = ["hydration"] } [features] -ssr = ["yew/ssr"] -hydration = ["yew/hydration"] +ssr = ["yew/ssr", "yew-link/ssr", "yew-link/axum"] +hydration = ["yew/hydration", "yew-link/hydration"] diff --git a/examples/ssr_router/README.md b/examples/axum_ssr_router/README.md similarity index 100% rename from examples/ssr_router/README.md rename to examples/axum_ssr_router/README.md diff --git a/examples/axum_ssr_router/index.html b/examples/axum_ssr_router/index.html new file mode 100644 index 00000000000..98dfce4afeb --- /dev/null +++ b/examples/axum_ssr_router/index.html @@ -0,0 +1,17 @@ + + + + + + + + Yew SSR Router + + + + + + diff --git a/examples/ssr_router/src/bin/ssr_router_hydrate.rs b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs similarity index 75% rename from examples/ssr_router/src/bin/ssr_router_hydrate.rs rename to examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs index 8b0bc47b80a..571339c8071 100644 --- a/examples/ssr_router/src/bin/ssr_router_hydrate.rs +++ b/examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs @@ -1,4 +1,4 @@ -use function_router::App; +use axum_ssr_router::{App, AppProps, LINK_ENDPOINT}; fn main() { #[cfg(target_arch = "wasm32")] @@ -11,5 +11,8 @@ fn main() { use tracing_subscriber::prelude::*; tracing_subscriber::registry().with(fmt_layer).init(); } - yew::Renderer::::new().hydrate(); + yew::Renderer::::with_props(AppProps { + endpoint: LINK_ENDPOINT.into(), + }) + .hydrate(); } diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/axum_ssr_router/src/bin/ssr_router_server.rs similarity index 80% rename from examples/ssr_router/src/bin/ssr_router_server.rs rename to examples/axum_ssr_router/src/bin/ssr_router_server.rs index e042ad6307e..b6d93272f30 100644 --- a/examples/ssr_router/src/bin/ssr_router_server.rs +++ b/examples/axum_ssr_router/src/bin/ssr_router_server.rs @@ -9,9 +9,12 @@ use axum::extract::{Query, Request, State}; use axum::handler::HandlerWithoutStateExt; use axum::http::Uri; use axum::response::IntoResponse; -use axum::routing::get; +use axum::routing::{get, post}; +use axum_ssr_router::{ + LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, +}; use clap::Parser; -use function_router::{Route, ServerApp, ServerAppProps, route_meta}; +use function_router::{Route, route_meta}; use futures::stream::{self, StreamExt}; use hyper::body::Incoming; use hyper_util::rt::TokioIo; @@ -21,6 +24,8 @@ use tower::Service; use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use yew::platform::Runtime; +use yew_link::axum::linked_state_handler; +use yew_link::{Resolver, ResolverProp}; use yew_router::prelude::Routable; // We use jemalloc as it produces better performance. @@ -45,25 +50,36 @@ fn head_tags_for(path: &str) -> String { ) } +#[derive(Clone)] +struct AppState { + index_html_before: String, + index_html_after: String, + resolver: ResolverProp, +} + async fn render( url: Uri, Query(queries): Query>, - State((index_html_before, index_html_after)): State<(String, String)>, + State(state): State, ) -> impl IntoResponse { let path = url.path().to_owned(); // Inject route-specific tags before , outside of Yew rendering. - let before = index_html_before.replace("", &format!("{}", head_tags_for(&path))); + let before = state + .index_html_before + .replace("", &format!("{}", head_tags_for(&path))); + let resolver = state.resolver.clone(); let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps { url: path.into(), queries, + resolver, }); Body::from_stream( stream::once(async move { before }) .chain(renderer.render_stream()) - .chain(stream::once(async move { index_html_after })) + .chain(stream::once(async move { state.index_html_after })) .map(Result::<_, Infallible>::Ok), ) } @@ -93,11 +109,16 @@ where #[tokio::main] async fn main() -> Result<(), Box> { let exec = Executor::default(); - env_logger::init(); - let opts = Opt::parse(); + let resolver_prop: ResolverProp = Resolver::new() + .register_linked::(()) + .register_linked::(()) + .register_linked::(()) + .into(); + let arc_resolver = resolver_prop.0.clone(); + let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html")) .await .expect("failed to read index.html"); @@ -105,23 +126,27 @@ async fn main() -> Result<(), Box> { let (index_html_before, index_html_after) = index_html_s.split_once("").unwrap(); let mut index_html_before = index_html_before.to_owned(); index_html_before.push_str(""); - let index_html_after = index_html_after.to_owned(); + let app_state = AppState { + index_html_before, + index_html_after, + resolver: resolver_prop, + }; + let app = Router::new() + .route( + LINK_ENDPOINT, + post(linked_state_handler).with_state(arc_resolver), + ) .fallback_service( ServeDir::new(opts.dir) .append_index_html_on_directories(false) - .fallback( - get(render) - .with_state((index_html_before.clone(), index_html_after.clone())) - .into_service(), - ), + .fallback(get(render).with_state(app_state).into_service()), ) .layer(CorsLayer::permissive()); let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); - println!("You can view the website at: http://localhost:8080/"); let listener = TcpListener::bind(addr).await?; diff --git a/examples/axum_ssr_router/src/lib.rs b/examples/axum_ssr_router/src/lib.rs new file mode 100644 index 00000000000..913510c7dfc --- /dev/null +++ b/examples/axum_ssr_router/src/lib.rs @@ -0,0 +1,467 @@ +use std::collections::HashMap; + +#[cfg(not(target_arch = "wasm32"))] +use function_router::Generated; +use function_router::content; +use rand::RngExt; +use serde::{Deserialize, Serialize}; +use yew::prelude::*; +use yew_link::{LinkProvider, ResolverProp, linked_state, use_linked_state}; +use yew_router::prelude::*; + +pub const LINK_ENDPOINT: &str = "/api/link"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPost(pub content::Post); + +#[linked_state] +impl LinkedState for LinkedPost { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Post::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedAuthor(pub content::Author); + +#[linked_state] +impl LinkedState for LinkedAuthor { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::Author::generate_from_seed(*seed)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinkedPostMeta(pub content::PostMeta); + +#[linked_state] +impl LinkedState for LinkedPostMeta { + type Context = (); + type Input = u32; + + async fn resolve(_ctx: &(), seed: &u32) -> Self { + Self(content::PostMeta::generate_from_seed(*seed)) + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct PostProps { + pub id: u32, +} + +#[component] +pub fn PostPage(props: &PostProps) -> HtmlResult { + let post = use_linked_state::(props.id)?.data(); + Ok(render_post(&post.0)) +} + +fn render_post(post: &content::Post) -> Html { + use content::PostPart; + + let render_quote = |quote: &content::Quote| { + html! { +
+
+

+ 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; + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect::>() + }); + + let on_complete = { + let seeds = seeds.clone(); + Callback::from(move |_| { + use rand::distr; + seeds.set( + rand::rng() + .sample_iter(distr::StandardUniform) + .take(2) + .collect(), + ); + }) + }; + + html! { +
+
+
+
+

{ "Authors" }

+

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

+
+
+
+

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

+
+
+ for seed in seeds.iter().copied() { +
+
+
{"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..29b5d731867 --- /dev/null +++ b/examples/axum_ssr_router/tests/e2e.rs @@ -0,0 +1,31 @@ +#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + +use axum_ssr_router::{App, AppProps, LINK_ENDPOINT}; +use ssr_e2e_harness::{ + assert_hydrate_home, assert_ssr_hydration_and_client_navigation, output_element, +}; +use wasm_bindgen_test::*; +use yew::Renderer; + +wasm_bindgen_test_configure!(run_in_browser); + +const SERVER_BASE: &str = "http://127.0.0.1:8080"; + +fn make_renderer() -> Renderer { + Renderer::::with_root_and_props( + output_element(), + AppProps { + endpoint: format!("{SERVER_BASE}{LINK_ENDPOINT}").into(), + }, + ) +} + +#[wasm_bindgen_test] +async fn ssr_hydration_and_client_navigation() { + assert_ssr_hydration_and_client_navigation(make_renderer, SERVER_BASE, LINK_ENDPOINT).await; +} + +#[wasm_bindgen_test] +async fn hydrate_home() { + assert_hydrate_home(make_renderer, SERVER_BASE).await; +} diff --git a/examples/function_router/src/content.rs b/examples/function_router/src/content.rs index 2a9ab46132e..8834f66694f 100644 --- a/examples/function_router/src/content.rs +++ b/examples/function_router/src/content.rs @@ -1,6 +1,8 @@ +use serde::{Deserialize, Serialize}; + use crate::generator::{Generated, Generator}; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Author { pub seed: u32, pub name: String, @@ -22,7 +24,7 @@ impl Generated for Author { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct PostMeta { pub seed: u32, pub title: String, @@ -48,7 +50,7 @@ impl Generated for PostMeta { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Post { pub meta: PostMeta, pub content: Vec, @@ -68,7 +70,7 @@ impl Generated for Post { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum PostPart { Section(Section), Quote(Quote), @@ -87,7 +89,7 @@ impl Generated for PostPart { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Section { pub title: String, pub paragraphs: Vec, @@ -112,7 +114,7 @@ impl Generated for Section { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct Quote { pub author: Author, pub content: String, diff --git a/examples/function_router/src/lib.rs b/examples/function_router/src/lib.rs index ad349dca7b0..e1679e7b0a4 100644 --- a/examples/function_router/src/lib.rs +++ b/examples/function_router/src/lib.rs @@ -14,11 +14,11 @@ // Hence, it may not yield the same value on the client and server side. mod app; -mod components; -mod content; +pub mod components; +pub mod content; mod generator; pub mod imagegen; -mod pages; +pub mod pages; pub use app::*; pub use content::*; diff --git a/examples/ssr_router/src/lib.rs b/examples/ssr_router/src/lib.rs deleted file mode 100644 index 8b137891791..00000000000 --- a/examples/ssr_router/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/ssr_router/tests/e2e.rs b/examples/ssr_router/tests/e2e.rs deleted file mode 100644 index 73ba7bb29d1..00000000000 --- a/examples/ssr_router/tests/e2e.rs +++ /dev/null @@ -1,72 +0,0 @@ -#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] - -use std::time::Duration; - -use function_router::App; -use gloo::utils::document; -use ssr_e2e_harness::{output_element, setup_ssr_page, wait_for}; -use wasm_bindgen_test::*; -use yew::platform::time::sleep; - -wasm_bindgen_test_configure!(run_in_browser); - -const SERVER_BASE: &str = "http://127.0.0.1:8080"; - -fn get_title_text() -> Option { - document() - .query_selector("h1.title") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) -} - -#[wasm_bindgen_test] -async fn hydrate_post_page() { - setup_ssr_page(SERVER_BASE, "/posts/0").await; - yew::Renderer::::with_root(output_element()).hydrate(); - - wait_for( - || { - let html = output_element().inner_html(); - html.contains("

") && !html.contains("Loading post...") - }, - 5000, - "post page content", - ) - .await; - - let title = get_title_text().expect("h1.title should be present on the post page"); - assert!(!title.is_empty(), "post title should not be empty"); -} - -#[wasm_bindgen_test] -async fn hydrate_posts_list() { - setup_ssr_page(SERVER_BASE, "/posts").await; - yew::Renderer::::with_root(output_element()).hydrate(); - - wait_for( - || { - document() - .query_selector("a.title.is-block") - .ok() - .flatten() - .is_some() - }, - 10000, - "post links to appear on /posts", - ) - .await; -} - -#[wasm_bindgen_test] -async fn hydrate_home() { - setup_ssr_page(SERVER_BASE, "/").await; - yew::Renderer::::with_root(output_element()).hydrate(); - - sleep(Duration::from_secs(2)).await; - let html = output_element().inner_html(); - assert!( - html.contains("Welcome"), - "home page should have content after hydration" - ); -} diff --git a/packages/yew-link-macro/Cargo.toml b/packages/yew-link-macro/Cargo.toml new file mode 100644 index 00000000000..8424385614b --- /dev/null +++ b/packages/yew-link-macro/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "yew-link-macro" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "Proc macros for yew-link" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true, features = ["full"] } + +[lints] +workspace = true diff --git a/packages/yew-link-macro/release.toml b/packages/yew-link-macro/release.toml new file mode 100644 index 00000000000..8f141163faa --- /dev/null +++ b/packages/yew-link-macro/release.toml @@ -0,0 +1 @@ +tag = false diff --git a/packages/yew-link-macro/src/lib.rs b/packages/yew-link-macro/src/lib.rs new file mode 100644 index 00000000000..0a4fc672f16 --- /dev/null +++ b/packages/yew-link-macro/src/lib.rs @@ -0,0 +1,149 @@ +use proc_macro::TokenStream; +use proc_macro2::Span; +use quote::quote; +use syn::{FnArg, Ident, ImplItem, ImplItemFn, ItemImpl, Pat, PatType, parse_macro_input}; + +/// Derive a [`LinkedState`] implementation from an impl block that declares +/// `type Context`, `type Input`, and `async fn resolve`. +/// +/// On all targets the macro emits `impl LinkedState for T { type Input = …; type Error = …; }`. +/// +/// On the server (`not(target_arch = "wasm32")`) it additionally emits +/// `impl LinkedStateResolve for T { … }` with the user-provided resolve body. +/// This half is stripped from WASM bundles automatically. +/// +/// ## `type Error` (optional) +/// +/// If `type Error` is omitted, it defaults to [`yew_link::Never`] (an uninhabited +/// serde-compatible error type) and the resolve body is wrapped in `Ok(…)` +/// automatically. When `type Error` is present, the resolve body must return +/// `Result`. +/// +/// # Example +/// +/// ```ignore +/// #[linked_state] +/// impl LinkedState for Post { +/// type Context = DbPool; +/// type Input = u32; +/// +/// async fn resolve(ctx: &DbPool, id: &u32) -> Self { +/// ctx.get_post(*id).await +/// } +/// } +/// +/// // With a typed error: +/// #[linked_state] +/// impl LinkedState for Post { +/// type Context = DbPool; +/// type Input = u32; +/// type Error = ApiError; +/// +/// async fn resolve(ctx: &DbPool, id: &u32) -> Result { +/// ctx.get_post(*id).await.map_err(ApiError::from) +/// } +/// } +/// ``` +#[proc_macro_attribute] +pub fn linked_state(_attr: TokenStream, item: TokenStream) -> TokenStream { + let impl_block = parse_macro_input!(item as ItemImpl); + match expand(impl_block) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand(impl_block: ItemImpl) -> syn::Result { + let self_ty = &impl_block.self_ty; + let (impl_generics, ty_generics, where_clause) = impl_block.generics.split_for_impl(); + + let mut input_ty = None; + let mut context_ty = None; + let mut error_ty: Option<&syn::Type> = None; + let mut resolve_fn: Option<&ImplItemFn> = None; + + for item in &impl_block.items { + match item { + ImplItem::Type(t) if t.ident == "Input" => input_ty = Some(&t.ty), + ImplItem::Type(t) if t.ident == "Context" => context_ty = Some(&t.ty), + ImplItem::Type(t) if t.ident == "Error" => error_ty = Some(&t.ty), + ImplItem::Fn(f) if f.sig.ident == "resolve" => resolve_fn = Some(f), + other => { + return Err(syn::Error::new_spanned( + other, + "#[linked_state] expects only `type Input`, `type Context`, `type Error` \ + (optional), and `async fn resolve`", + )); + } + } + } + + let input_ty = + input_ty.ok_or_else(|| syn::Error::new(Span::call_site(), "missing `type Input`"))?; + let context_ty = + context_ty.ok_or_else(|| syn::Error::new(Span::call_site(), "missing `type Context`"))?; + let resolve_fn = resolve_fn + .ok_or_else(|| syn::Error::new(Span::call_site(), "missing `async fn resolve`"))?; + + if resolve_fn.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + resolve_fn.sig.fn_token, + "`resolve` must be an async fn", + )); + } + + let params: Vec<_> = resolve_fn.sig.inputs.iter().collect(); + if params.len() != 2 { + return Err(syn::Error::new_spanned( + &resolve_fn.sig.inputs, + "`resolve` must take exactly two parameters: context and input references", + )); + } + + let ctx_name = param_ident(params[0])?; + let input_name = param_ident(params[1])?; + + let resolve_stmts = &resolve_fn.block.stmts; + + let (error_ty_tokens, resolve_body) = match error_ty { + Some(ty) => (quote! { #ty }, quote! { #(#resolve_stmts)* }), + None => ( + quote! { ::yew_link::Never }, + quote! { ::core::result::Result::Ok({ #(#resolve_stmts)* }) }, + ), + }; + + Ok(quote! { + impl #impl_generics ::yew_link::LinkedState for #self_ty #ty_generics #where_clause { + type Input = #input_ty; + type Error = #error_ty_tokens; + const TYPE_KEY: &'static str = + ::core::concat!(::core::module_path!(), "::", ::core::stringify!(#self_ty)); + } + + #[cfg(not(target_arch = "wasm32"))] + impl #impl_generics ::yew_link::LinkedStateResolve for #self_ty #ty_generics #where_clause { + type Context = #context_ty; + + async fn resolve<'__yew_link>( + #ctx_name: &'__yew_link Self::Context, + #input_name: &'__yew_link ::Input, + ) -> ::core::result::Result::Error> { + #resolve_body + } + } + }) +} + +fn param_ident(arg: &FnArg) -> syn::Result<&Ident> { + match arg { + FnArg::Typed(PatType { pat, .. }) => match pat.as_ref() { + Pat::Ident(pi) => Ok(&pi.ident), + _ => Err(syn::Error::new_spanned(pat, "expected an identifier")), + }, + other => Err(syn::Error::new_spanned( + other, + "unexpected `self` parameter", + )), + } +} diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml new file mode 100644 index 00000000000..6b801a6c854 --- /dev/null +++ b/packages/yew-link/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "yew-link" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +description = "Server-client state linking for Yew SSR" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +yew = { path = "../yew" } +yew-link-macro = { path = "../yew-link-macro" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +gloo-net = { version = "0.6", features = ["http"] } +lru = "0.16" +wasm-bindgen-futures = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +axum = { workspace = true, optional = true } +actix-web = { workspace = true, optional = true } + +[features] +default = [] +ssr = ["yew/ssr"] +hydration = ["yew/hydration"] +axum = ["dep:axum"] +actix = ["dep:actix-web"] + +[lints] +workspace = true diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs new file mode 100644 index 00000000000..2938d577826 --- /dev/null +++ b/packages/yew-link/src/lib.rs @@ -0,0 +1,684 @@ +use std::any::{Any, TypeId}; +use std::cell::RefCell; +use std::collections::HashMap; +#[cfg(target_arch = "wasm32")] +use std::collections::HashSet; +use std::fmt; +use std::hash::Hash; +#[cfg(target_arch = "wasm32")] +use std::num::NonZeroUsize; +use std::pin::Pin; +use std::rc::Rc; +use std::sync::Arc; + +#[cfg(target_arch = "wasm32")] +use lru::LruCache; +use serde::Serialize; +use serde::de::DeserializeOwned; +use yew::prelude::*; +#[cfg(target_arch = "wasm32")] +use yew::suspense::Suspension; +use yew::suspense::SuspensionResult; +pub use yew_link_macro::linked_state; + +/// A type that can be resolved on the server and transferred to the client. +/// +/// Implementors declare an `Input` type and an `Error` type. The actual resolve +/// logic is provided separately via [`Resolver::register`] or the +/// [`#[linked_state]`](linked_state) macro. +pub trait LinkedState: Serialize + DeserializeOwned + Clone + 'static { + /// The input/deps used to look up this state. + type Input: Serialize + DeserializeOwned + PartialEq + Eq + Hash + Clone + fmt::Debug + 'static; + + /// Application-level error returned by a failed resolve. + type Error: Serialize + DeserializeOwned + Clone + fmt::Debug + fmt::Display + 'static; + + /// Stable wire-format key used to route requests between client and server. + /// + /// Generated automatically by [`#[linked_state]`](linked_state) as + /// `concat!(module_path!(), "::", stringify!(Type))`. If you implement + /// `LinkedState` manually (e.g. for generic types), set this to a + /// string that is identical across server and client builds. + const TYPE_KEY: &'static str; +} + +/// Server-side extension of [`LinkedState`] that provides a resolve function. +/// +/// You normally don't implement this by hand — use the [`#[linked_state]`](linked_state) +/// attribute macro instead. The macro generates this impl (gated behind +/// `#[cfg(not(target_arch = "wasm32"))]`) and strips the server code from +/// WASM bundles automatically. +#[cfg(not(target_arch = "wasm32"))] +pub trait LinkedStateResolve: LinkedState { + type Context: Send + Sync + 'static; + + fn resolve<'a>( + ctx: &'a Self::Context, + input: &'a Self::Input, + ) -> impl Future> + Send + 'a; +} + +/// An uninhabited error type that implements `Serialize`/`Deserialize`. +/// +/// Used as the default `LinkedState::Error` when `type Error` is omitted from +/// the `#[linked_state]` macro. Unlike `std::convert::Infallible`, this type +/// satisfies the serde bounds required by the trait. +#[derive(Clone, Debug, Serialize, serde::Deserialize)] +pub enum Never {} + +impl fmt::Display for Never { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self {} + } +} + +/// Error returned by [`use_linked_state`]. +/// +/// Distinguishes application-level errors (from the resolve function) from +/// infrastructure failures (network, serialization, missing resolver). +#[derive(Serialize, serde::Deserialize, Clone, Debug)] +pub enum LinkError { + /// The resolve function returned an application-level error. + Resolve(E), + /// Infrastructure failure (network, serialization, missing resolver). + Internal(String), +} + +impl fmt::Display for LinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Resolve(e) => fmt::Display::fmt(e, f), + Self::Internal(s) => f.write_str(s), + } + } +} + +/// Handle returned by [`use_linked_state`]. +/// +/// Provides access to the resolved data, a [`refresh`](Self::refresh) +/// method that triggers a background re-fetch, and an +/// [`is_refreshing`](Self::is_refreshing) method to check whether a refresh +/// is in progress (stale-while-revalidate). +#[derive(Clone)] +pub struct LinkedStateHandle { + result: Result, LinkError>, + refresh: Callback<()>, + refreshing: bool, +} + +impl LinkedStateHandle { + /// Returns the resolved value, panicking if the resolver returned an error. + /// + /// This clones the inner [`Rc`], which is cheap. + /// + /// # Panics + /// + /// Panics if the resolver returned a [`LinkError`]. Use + /// [`as_result`](Self::as_result) for non-panicking access. + pub fn data(&self) -> Rc { + self.result.as_ref().unwrap().clone() + } + + /// Returns a reference to the underlying result. + pub fn as_result(&self) -> &Result, LinkError> { + &self.result + } + + /// Triggers a background re-fetch of this linked state. + /// + /// Unlike a full suspend, the component keeps displaying the previous + /// (stale) value while the fresh data is being fetched. Use + /// [`is_refreshing`](Self::is_refreshing) to show a loading indicator + /// alongside the stale data. + /// + /// On the server this is a no-op. + pub fn refresh(&self) { + self.refresh.emit(()); + } + + /// Returns `true` while a background refresh is in progress and this + /// handle still holds the previous (stale) value. + pub fn is_refreshing(&self) -> bool { + self.refreshing + } +} + +#[doc(hidden)] +#[derive(Serialize, serde::Deserialize)] +pub struct LinkRequest { + type_key: String, + input: serde_json::Value, +} + +#[doc(hidden)] +#[derive(Serialize, serde::Deserialize)] +pub struct LinkResponse { + #[serde(default)] + ok: Option, + #[serde(default)] + error: Option, +} + +type ResolveBoxFuture = + Pin> + Send>>; +type ResolverFn = Box ResolveBoxFuture + Send + Sync>; + +/// Registry of resolve functions, keyed by [`std::any::type_name`]. +/// +/// Constructed on the server and passed to [`LinkProvider`]. Also used by the +/// axum handler when the `axum` feature is enabled. +pub struct Resolver { + handlers: HashMap<&'static str, ResolverFn>, +} + +impl fmt::Debug for Resolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Resolver") + .field("types", &self.handlers.keys().collect::>()) + .finish() + } +} + +impl Resolver { + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + /// Register a resolver for `T`. The closure receives `T::Input` and returns + /// a future that produces `Result`. + pub fn register(mut self, f: F) -> Self + where + T: LinkedState + Send, + T::Error: Send, + F: Fn(T::Input) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.handlers.insert( + T::TYPE_KEY, + Box::new(move |input_json: serde_json::Value| { + let input: T::Input = match serde_json::from_value(input_json) { + Ok(v) => v, + Err(e) => { + return Box::pin(async move { + Err(serde_json::Value::String(format!( + "failed to deserialize input: {e}" + ))) + }); + } + }; + let fut = f(input); + Box::pin(async move { + match fut.await { + Ok(val) => serde_json::to_value(&val) + .map_err(|e| serde_json::Value::String(e.to_string())), + Err(e) => Err(serde_json::to_value(&e).unwrap_or_else(|ser_err| { + serde_json::Value::String(format!( + "{e}: (serialization failed: {ser_err})" + )) + })), + } + }) + }), + ); + self + } + + /// Resolve a [`LinkRequest`]. + pub async fn resolve_request( + &self, + req: &LinkRequest, + ) -> Result { + let handler = self.handlers.get(req.type_key.as_str()).ok_or_else(|| { + serde_json::Value::String(format!("no resolver registered for {}", req.type_key)) + })?; + handler(req.input.clone()).await + } +} + +impl Default for Resolver { + fn default() -> Self { + Self::new() + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Resolver { + /// Register a resolver for `T` using its [`LinkedStateResolve`] impl. + /// + /// The `ctx` is wrapped in an [`Arc`] internally so clones are cheap. + pub fn register_linked(self, ctx: T::Context) -> Self + where + T: LinkedStateResolve + Send + 'static, + T::Input: Send, + T::Error: Send, + { + let ctx = Arc::new(ctx); + self.register::(move |input| { + let ctx = ctx.clone(); + async move { T::resolve(&*ctx, &input).await } + }) + } +} + +#[derive(Clone)] +struct CacheKey { + type_id: TypeId, + input_hash: u64, + input: Rc, + eq_fn: fn(&dyn Any, &dyn Any) -> bool, +} + +impl Hash for CacheKey { + fn hash(&self, state: &mut H) { + self.type_id.hash(state); + self.input_hash.hash(state); + } +} + +impl PartialEq for CacheKey { + fn eq(&self, other: &Self) -> bool { + self.type_id == other.type_id && (self.eq_fn)(&*self.input, &*other.input) + } +} + +impl Eq for CacheKey {} + +#[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))] +fn eq_inputs(a: &dyn Any, b: &dyn Any) -> bool { + a.downcast_ref::() + .zip(b.downcast_ref::()) + .is_some_and(|(a, b)| a == b) +} + +#[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))] +fn cache_key(input: &T::Input) -> CacheKey { + use std::hash::Hasher; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + input.hash(&mut hasher); + CacheKey { + type_id: TypeId::of::(), + input_hash: hasher.finish(), + input: Rc::new(input.clone()), + eq_fn: eq_inputs::, + } +} + +#[cfg(target_arch = "wasm32")] +type Cache = Rc>>; +#[cfg(not(target_arch = "wasm32"))] +type Cache = Rc>>; + +#[cfg(target_arch = "wasm32")] +type InFlight = Rc>>; + +#[cfg(target_arch = "wasm32")] +type Refreshing = Rc>>; + +#[derive(Clone)] +struct LinkContextInner { + cache: Cache, + #[cfg(target_arch = "wasm32")] + in_flight: InFlight, + #[cfg(target_arch = "wasm32")] + refreshing: Refreshing, + endpoint: AttrValue, + #[cfg(feature = "ssr")] + resolver: Option>, +} + +impl PartialEq for LinkContextInner { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.cache, &other.cache) && self.endpoint == other.endpoint && { + #[cfg(target_arch = "wasm32")] + { + Rc::ptr_eq(&self.in_flight, &other.in_flight) + && Rc::ptr_eq(&self.refreshing, &other.refreshing) + } + #[cfg(not(target_arch = "wasm32"))] + { + true + } + } + } +} + +impl LinkContextInner { + #[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))] + async fn fetch_remote( + &self, + input: &T::Input, + ) -> Result> { + use gloo_net::http::Request; + + let req_body = LinkRequest { + type_key: T::TYPE_KEY.to_string(), + input: serde_json::to_value(input).map_err(|e| LinkError::Internal(e.to_string()))?, + }; + let resp = Request::post(self.endpoint.as_ref()) + .json(&req_body) + .map_err(|e| LinkError::Internal(e.to_string()))? + .send() + .await + .map_err(|e| LinkError::Internal(e.to_string()))?; + + let link_resp: LinkResponse = resp + .json() + .await + .map_err(|e| LinkError::Internal(e.to_string()))?; + + match link_resp.ok { + Some(val) => { + serde_json::from_value(val).map_err(|e| LinkError::Internal(e.to_string())) + } + None => match link_resp.error { + Some(err_val) => { + let e: T::Error = serde_json::from_value(err_val) + .map_err(|e| LinkError::Internal(e.to_string()))?; + Err(LinkError::Resolve(e)) + } + None => Err(LinkError::Internal("unknown error".into())), + }, + } + } + + #[cfg(feature = "ssr")] + async fn resolve_local( + &self, + input: &T::Input, + ) -> Result> { + let resolver = self + .resolver + .as_ref() + .expect("resolver not set on server-side LinkProvider"); + let req = LinkRequest { + type_key: T::TYPE_KEY.to_string(), + input: serde_json::to_value(input).map_err(|e| LinkError::Internal(e.to_string()))?, + }; + match resolver.resolve_request(&req).await { + Ok(val) => serde_json::from_value(val).map_err(|e| LinkError::Internal(e.to_string())), + Err(err_val) => { + let e: T::Error = serde_json::from_value(err_val) + .map_err(|e| LinkError::Internal(e.to_string()))?; + Err(LinkError::Resolve(e)) + } + } + } +} + +/// Wrapper so [`Resolver`] can be passed as a component prop. +/// +/// Uses `Arc` internally so it is `Send` (required by `ServerRenderer::with_props`). +#[derive(Clone)] +pub struct ResolverProp(pub Arc); + +impl fmt::Debug for ResolverProp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("ResolverProp").field(&self.0).finish() + } +} + +impl PartialEq for ResolverProp { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for ResolverProp {} + +impl From for ResolverProp { + fn from(r: Resolver) -> Self { + Self(Arc::new(r)) + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct LinkProviderProps { + pub children: Children, + /// Remote endpoint URL used by the client to fetch linked states. + #[prop_or_default] + pub endpoint: AttrValue, + /// Server-side resolver. Ignored on wasm32 targets. + #[prop_or_default] + pub resolver: Option, + /// Maximum number of entries in the linked-state cache. Defaults to 64. + #[prop_or(64)] + pub cache_capacity: usize, +} + +/// Provides linked-state resolution context to descendant components. +/// +/// On the server, pass a [`ResolverProp`] so that [`use_linked_state`] can +/// resolve states locally. On the client, pass an `endpoint` URL. +#[component] +pub fn LinkProvider(props: &LinkProviderProps) -> Html { + #[cfg(target_arch = "wasm32")] + let cache: Cache = { + let cap = NonZeroUsize::new(props.cache_capacity).unwrap_or(NonZeroUsize::MIN); + (*use_ref(|| Rc::new(RefCell::new(LruCache::new(cap))))).clone() + }; + #[cfg(not(target_arch = "wasm32"))] + let cache: Cache = (*use_ref(|| Rc::new(RefCell::new(HashMap::new())))).clone(); + #[cfg(target_arch = "wasm32")] + let in_flight: InFlight = (*use_ref(|| Rc::new(RefCell::new(HashMap::new())))).clone(); + #[cfg(target_arch = "wasm32")] + let refreshing: Refreshing = (*use_ref(|| Rc::new(RefCell::new(HashSet::new())))).clone(); + + let ctx = LinkContextInner { + cache, + #[cfg(target_arch = "wasm32")] + in_flight, + #[cfg(target_arch = "wasm32")] + refreshing, + endpoint: props.endpoint.clone(), + #[cfg(feature = "ssr")] + resolver: props.resolver.as_ref().map(|r| Arc::clone(&r.0)), + }; + + html! { + context={ctx}> + { for props.children.iter() } + > + } +} + +/// Fetch a [`LinkedState`] value. +/// +/// On the server the state is resolved locally via the [`Resolver`] registered +/// in the ancestor [`LinkProvider`], and the result is embedded in the SSR +/// HTML for zero-cost hydration on the client. +/// +/// On the client during hydration, the SSR-embedded state is read directly +/// without any network request. On subsequent client-side navigations the +/// state is fetched from the provider's `endpoint` URL. +/// +/// The hook suspends while the state is being resolved/fetched for the first +/// time. On [`refresh`](LinkedStateHandle::refresh), the previous value is +/// kept visible (stale-while-revalidate) and +/// [`is_refreshing`](LinkedStateHandle::is_refreshing) returns `true` until +/// the fresh data arrives. +/// +/// Multiple components requesting the same `(T, Input)` concurrently share a +/// single in-flight request. +/// +/// # Panics +/// +/// Panics if there is no ancestor [`LinkProvider`] in the component tree. +#[hook] +pub fn use_linked_state(input: T::Input) -> SuspensionResult> { + #[cfg(any(feature = "ssr", target_arch = "wasm32"))] + let link_ctx = + use_context::().expect("use_linked_state requires a LinkProvider"); + + #[cfg(any(feature = "ssr", target_arch = "wasm32"))] + type Prepared = Result>; + + #[cfg(feature = "ssr")] + { + let prepared = { + let link_ctx = link_ctx.clone(); + yew::functional::use_prepared_state_with_suspension( + input, + move |input: Rc| { + let link_ctx = link_ctx.clone(); + async move { link_ctx.resolve_local::(&input).await } + }, + ) + }?; + + let result: Rc> = + prepared.expect("prepared state should always be Some on SSR"); + Ok(LinkedStateHandle { + result: match result.as_ref() { + Ok(val) => Ok(Rc::new(val.clone())), + Err(e) => Err(e.clone()), + }, + refresh: Callback::from(|_: ()| {}), + refreshing: false, + }) + } + + #[cfg(all(not(feature = "ssr"), not(target_arch = "wasm32")))] + { + let _ = input; + Ok(LinkedStateHandle { + result: Err(LinkError::Internal( + "yew-link requires the `ssr` feature (server) or a wasm32 target (client)".into(), + )), + refresh: Callback::from(|_: ()| {}), + refreshing: false, + }) + } + + #[cfg(all(not(feature = "ssr"), target_arch = "wasm32"))] + { + let prepared = + yew::functional::use_prepared_state::, T::Input>(input.clone())?; + + let key = cache_key::(&input); + + if let Some(ref result) = prepared { + if let Ok(json_val) = serde_json::to_value(result.as_ref()) { + let mut cache = link_ctx.cache.borrow_mut(); + if cache.peek(&key).is_none() { + cache.put(key.clone(), json_val); + } + } + } + + let force_update = use_force_update(); + let has_refreshed = use_ref(|| std::cell::Cell::new(false)); + + let refresh = { + let cache = link_ctx.cache.clone(); + let refreshing = link_ctx.refreshing.clone(); + let key = key.clone(); + let link_ctx = link_ctx.clone(); + let input = input.clone(); + let force_update = force_update.clone(); + let has_refreshed = has_refreshed.clone(); + Callback::from(move |()| { + refreshing.borrow_mut().insert(key.clone()); + has_refreshed.set(true); + + let link_ctx = link_ctx.clone(); + let key = key.clone(); + let cache = cache.clone(); + let refreshing = refreshing.clone(); + let inner_force_update = force_update.clone(); + let input = input.clone(); + wasm_bindgen_futures::spawn_local(async move { + let result: Result> = + link_ctx.fetch_remote::(&input).await; + + refreshing.borrow_mut().remove(&key); + + let should_cache = match &result { + Ok(_) | Err(LinkError::Resolve(_)) => true, + Err(LinkError::Internal(_)) => false, + }; + if should_cache { + if let Ok(json_val) = serde_json::to_value(&result) { + cache.borrow_mut().put(key, json_val); + } + } + + inner_force_update.force_update(); + }); + + force_update.force_update(); + }) + }; + + if !has_refreshed.get() { + if let Some(result) = prepared { + return Ok(LinkedStateHandle { + result: match result.as_ref() { + Ok(val) => Ok(Rc::new(val.clone())), + Err(e) => Err(e.clone()), + }, + refresh, + refreshing: false, + }); + } + } + + let is_refreshing = link_ctx.refreshing.borrow().contains(&key); + + if let Some(cached_val) = link_ctx.cache.borrow_mut().get(&key).cloned() { + if let Ok(result) = serde_json::from_value::>(cached_val) { + return Ok(LinkedStateHandle { + result: result.map(Rc::new), + refresh, + refreshing: is_refreshing, + }); + } + } + + if let Some(sus) = link_ctx.in_flight.borrow().get(&key).cloned() { + if !sus.resumed() { + return Err(sus); + } + if let Some(cached_val) = link_ctx.cache.borrow_mut().get(&key).cloned() { + if let Ok(result) = serde_json::from_value::>(cached_val) { + return Ok(LinkedStateHandle { + result: result.map(Rc::new), + refresh, + refreshing: false, + }); + } + } + } + + let sus = Suspension::from_future({ + let link_ctx = link_ctx.clone(); + let key = key.clone(); + async move { + let result: Result> = + link_ctx.fetch_remote::(&input).await; + + link_ctx.in_flight.borrow_mut().remove(&key); + + let should_cache = match &result { + Ok(_) | Err(LinkError::Resolve(_)) => true, + Err(LinkError::Internal(_)) => false, + }; + if should_cache { + if let Ok(json_val) = serde_json::to_value(&result) { + link_ctx.cache.borrow_mut().put(key, json_val); + } + } + } + }); + + link_ctx.in_flight.borrow_mut().insert(key, sus.clone()); + Err(sus) + } +} + +#[cfg(all(not(target_arch = "wasm32"), any(feature = "axum", feature = "actix")))] +mod services; + +#[cfg(all(not(target_arch = "wasm32"), any(feature = "axum", feature = "actix")))] +pub use services::*; diff --git a/packages/yew-link/src/services.rs b/packages/yew-link/src/services.rs new file mode 100644 index 00000000000..a06f15706ee --- /dev/null +++ b/packages/yew-link/src/services.rs @@ -0,0 +1,134 @@ +#[cfg(feature = "axum")] +pub mod axum { + use std::sync::Arc; + + use axum::Json; + use axum::extract::State; + use axum::http::StatusCode; + use axum::response::IntoResponse; + + use crate::{LinkRequest, LinkResponse, Resolver}; + + /// Axum handler that resolves [`LinkRequest`]s. + /// + /// ``` + /// use std::sync::Arc; + /// + /// use serde::{Deserialize, Serialize}; + /// use yew_link::axum::linked_state_handler; + /// use yew_link::{LinkedState, Never, Resolver}; + /// + /// #[derive(Clone, Debug, Serialize, Deserialize)] + /// struct Post { + /// title: String, + /// } + /// + /// impl LinkedState for Post { + /// type Error = Never; + /// type Input = u32; + /// + /// const TYPE_KEY: &'static str = "Post"; + /// } + /// + /// async fn get_post(_id: u32) -> Result { + /// Ok(Post { + /// title: String::new(), + /// }) + /// } + /// + /// let resolver = Arc::new(Resolver::new().register::(|id| get_post(id))); + /// + /// let app: axum::Router = axum::Router::new().route( + /// "/api/link", + /// axum::routing::post(linked_state_handler).with_state(resolver), + /// ); + /// # let _ = app; + /// ``` + pub async fn linked_state_handler( + State(resolver): State>, + Json(req): Json, + ) -> impl IntoResponse { + match resolver.resolve_request(&req).await { + Ok(val) => ( + StatusCode::OK, + Json(LinkResponse { + ok: Some(val), + error: None, + }), + ), + Err(err_val) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(LinkResponse { + ok: None, + error: Some(err_val), + }), + ), + } + } +} + +#[cfg(feature = "actix")] +pub mod actix { + use actix_web::HttpResponse; + use actix_web::web::{Data, Json}; + + use crate::{LinkRequest, LinkResponse, Resolver}; + + /// Actix handler that resolves [`LinkRequest`]s. + /// + /// ```no_run + /// use actix_web::web::{Data, post}; + /// use actix_web::{App, HttpServer}; + /// use serde::{Deserialize, Serialize}; + /// use yew_link::actix::linked_state_handler; + /// use yew_link::{LinkedState, Never, Resolver}; + /// + /// #[derive(Clone, Debug, Serialize, Deserialize)] + /// struct Post { + /// title: String, + /// } + /// + /// impl LinkedState for Post { + /// type Error = Never; + /// type Input = u32; + /// + /// const TYPE_KEY: &'static str = "Post"; + /// } + /// + /// async fn get_post(_id: u32) -> Result { + /// Ok(Post { + /// title: String::new(), + /// }) + /// } + /// + /// #[actix_web::main] + /// async fn main() -> std::io::Result<()> { + /// let resolver = Data::new(Resolver::new().register::(|id| get_post(id))); + /// + /// HttpServer::new(move || { + /// App::new() + /// .app_data(resolver.clone()) + /// .route("/api/link", post().to(linked_state_handler)) + /// }) + /// .bind(("0.0.0.0", 8080))? + /// .run() + /// .await + /// } + /// ``` + pub async fn linked_state_handler( + resolver: Data, + Json(req): Json, + ) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(LinkResponse { + ok: Some(val), + error: None, + }), + + Err(err_val) => HttpResponse::UnprocessableEntity().json(LinkResponse { + ok: None, + error: Some(err_val), + }), + } + } +} diff --git a/packages/yew-router/Cargo.toml b/packages/yew-router/Cargo.toml index 13181ab421f..0fab5a1f522 100644 --- a/packages/yew-router/Cargo.toml +++ b/packages/yew-router/Cargo.toml @@ -33,7 +33,7 @@ features = [ ] [dev-dependencies] -wasm-bindgen-test = "0.3" +wasm-bindgen-test.workspace = true serde = { workspace = true, features = ["derive"] } yew = { version = "0.23.0", path = "../yew", features = ["csr"] } diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml index 95d5244c8d4..629e50f9ccc 100644 --- a/packages/yew/Cargo.toml +++ b/packages/yew/Cargo.toml @@ -85,7 +85,7 @@ tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["macros", "rt", "time"] } [dev-dependencies] -wasm-bindgen-test = "0.3" +wasm-bindgen-test.workspace = true gloo = { workspace = true, features = ["futures"] } wasm-bindgen-futures.workspace = true trybuild = { workspace = true } diff --git a/packages/yew/tests/hydration.rs b/packages/yew/tests/hydration.rs index be29a2056ea..63ca2144edd 100644 --- a/packages/yew/tests/hydration.rs +++ b/packages/yew/tests/hydration.rs @@ -1107,7 +1107,7 @@ async fn hydrate_flicker() { #[wasm_bindgen_test] async fn hydration_with_camelcase_svg_elements() { - #[function_component] + #[component] fn SvgWithCamelCase() -> Html { html! { @@ -1131,7 +1131,7 @@ async fn hydration_with_camelcase_svg_elements() { } } - #[function_component] + #[component] fn App() -> Html { let counter = use_state(|| 0); let onclick = { diff --git a/tools/changelog/src/yew_package.rs b/tools/changelog/src/yew_package.rs index 30278c993d0..c7f467145b6 100644 --- a/tools/changelog/src/yew_package.rs +++ b/tools/changelog/src/yew_package.rs @@ -6,6 +6,7 @@ pub enum YewPackage { Yew, YewAgent, YewRouter, + YewLink, } impl YewPackage { @@ -14,6 +15,7 @@ impl YewPackage { YewPackage::Yew => &["A-yew", "A-yew-macro", "macro"], YewPackage::YewAgent => &["A-yew-agent"], YewPackage::YewRouter => &["A-yew-router", "A-yew-router-macro"], + YewPackage::YewLink => &["A-yew-link", "A-yew-link-macro"], } } } diff --git a/tools/ssr-e2e-harness/Cargo.toml b/tools/ssr-e2e-harness/Cargo.toml index d2dc8fbc28a..3c1ec539385 100644 --- a/tools/ssr-e2e-harness/Cargo.toml +++ b/tools/ssr-e2e-harness/Cargo.toml @@ -6,5 +6,6 @@ rust-version.workspace = true [dependencies] gloo = { workspace = true, features = ["futures"] } -web-sys = { workspace = true } +web-sys = { workspace = true, features = ["Performance", "PerformanceEntry"] } wasm-bindgen = { workspace = true } +yew = { path = "../../packages/yew", features = ["csr", "hydration", "test"] } diff --git a/tools/ssr-e2e-harness/src/lib.rs b/tools/ssr-e2e-harness/src/lib.rs index 6309557cd9c..b36dd9a0fef 100644 --- a/tools/ssr-e2e-harness/src/lib.rs +++ b/tools/ssr-e2e-harness/src/lib.rs @@ -2,6 +2,8 @@ use std::time::Duration; use gloo::utils::document; use wasm_bindgen::prelude::*; +use yew::Renderer; +use yew::html::BaseComponent; /// Returns the `
` element used by wasm-bindgen-test as the /// test output container. @@ -56,3 +58,228 @@ pub fn push_route(path: &str) { .push_state_with_url(&JsValue::NULL, "", Some(path)) .unwrap(); } + +fn performance() -> web_sys::Performance { + web_sys::window().unwrap().performance().unwrap() +} + +/// Counts completed network requests to URLs containing `needle` using the +/// Performance Resource Timing API. This works regardless of how the request +/// was initiated (gloo-net, window.fetch, XMLHttpRequest, etc.) because it +/// observes the browser's actual network activity. +pub fn resource_request_count(needle: &str) -> u32 { + let entries = performance().get_entries_by_type("resource"); + let mut count = 0; + for i in 0..entries.length() { + let entry: web_sys::PerformanceEntry = entries.get(i).unchecked_into(); + if entry.name().contains(needle) { + count += 1; + } + } + count +} + +/// Clears all resource timing entries so that subsequent calls to +/// [`resource_request_count`] only see new requests. +pub fn clear_resource_timings() { + performance().clear_resource_timings(); +} + +/// Returns the text content of the first `

` in the document. +pub fn get_title_text() -> Option { + document() + .query_selector("h1.title") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) +} + +/// Returns the text content of the first `.section.container` inside the +/// test output element. +pub fn post_body_text() -> String { + output_element() + .query_selector(".section.container") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) + .unwrap_or_default() +} + +/// Parses `html` into a detached container element and returns the text +/// content of the first element matching `selector`, if any. +pub fn extract_text_from_html(html: &str, selector: &str) -> Option { + let container = document().create_element("div").unwrap(); + container.set_inner_html(html); + container + .query_selector(selector) + .ok() + .flatten() + .and_then(|el| el.text_content()) +} + +/// Shared e2e scenario used by the yew-link SSR router examples. +/// +/// Phases: +/// 1. Directly visit `/posts/0` by fetching its SSR HTML, hydrate, and assert that hydration did +/// not trigger any fetch to `link_endpoint`. +/// 2. Click the "Posts" navbar link, then the post 0 card, and wait for the post page to render. +/// 3. Assert at least one fetch to `link_endpoint` happened during the client-side navigation and +/// that the rendered title/body match the original SSR HTML. +/// +/// `make_renderer` is a closure that builds a `Renderer` rooted at +/// [`output_element()`]. It is invoked after the SSR HTML has been injected +/// so that hydration picks it up. +pub async fn assert_ssr_hydration_and_client_navigation( + make_renderer: impl FnOnce() -> Renderer, + server_base: &str, + link_endpoint: &str, +) where + COMP: BaseComponent, +{ + // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to link_endpoint -- + + let ssr_html = fetch_ssr_html(server_base, "/posts/0").await; + let ssr_title = extract_text_from_html(&ssr_html, "h1.title") + .expect("SSR HTML for /posts/0 should contain h1.title"); + let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); + + clear_resource_timings(); + + output_element().set_inner_html(&ssr_html); + push_route("/posts/0"); + let app = make_renderer().hydrate(); + + wait_for( + || { + let html = output_element().inner_html(); + html.contains("

") && !html.contains("Loading post...") + }, + 5000, + "post page content after SSR hydration", + ) + .await; + + let link_fetches = resource_request_count(link_endpoint); + let title = get_title_text(); + + assert_eq!( + link_fetches, 0, + "direct SSR visit to /posts/0 should not trigger any fetch to {link_endpoint}" + ); + let title = title.expect("h1.title should be present on the SSR post page"); + assert!(!title.is_empty(), "SSR post title should not be empty"); + + // -- Part 2: Navigate to /posts within the same app, then to /posts/0 -- + + yew::scheduler::flush().await; + + clear_resource_timings(); + + let posts_link: web_sys::HtmlElement = output_element() + .query_selector("a.navbar-item[href='/posts']") + .unwrap() + .expect("Posts navbar link should exist") + .dyn_into() + .unwrap(); + posts_link.click(); + yew::scheduler::flush().await; + + wait_for( + || { + document() + .query_selector("a.title.is-block") + .ok() + .flatten() + .is_some() + && get_title_text().as_deref() == Some("Posts") + }, + 15000, + "posts list after client-side navigation to /posts", + ) + .await; + + clear_resource_timings(); + + wait_for( + || { + output_element() + .query_selector("a.title.is-block[href='/posts/0']") + .ok() + .flatten() + .is_some() + }, + 15000, + "post 0 card link on posts list", + ) + .await; + + let post_link: web_sys::HtmlElement = output_element() + .query_selector("a.title.is-block[href='/posts/0']") + .unwrap() + .unwrap() + .dyn_into() + .unwrap(); + post_link.click(); + yew::scheduler::flush().await; + + wait_for( + || { + document() + .query_selector("h2.subtitle") + .ok() + .flatten() + .map(|el| el.text_content().unwrap_or_default()) + .is_some_and(|text| text.starts_with("by ")) + }, + 15000, + "post page content after client-side navigation to /posts/0", + ) + .await; + + // -- Part 3: Verify fetch happened and content matches SSR -- + + let nav_link_fetches = resource_request_count(link_endpoint); + let nav_title = get_title_text(); + let nav_body = post_body_text(); + + assert!( + nav_link_fetches >= 1, + "client-side navigation to /posts/0 should trigger at least one fetch to {link_endpoint}, \ + got {nav_link_fetches}" + ); + + let nav_title = nav_title.expect("h1.title should be present after client-side navigation"); + assert_eq!( + ssr_title, nav_title, + "post title should match between SSR and client-side navigation" + ); + assert_eq!( + ssr_body, nav_body, + "post body should match between SSR and client-side navigation" + ); + + app.destroy(); + yew::scheduler::flush().await; +} + +/// Shared e2e scenario that asserts hydrating the home page ("/") produces +/// HTML containing the word "Welcome". +pub async fn assert_hydrate_home( + make_renderer: impl FnOnce() -> Renderer, + server_base: &str, +) where + COMP: BaseComponent, +{ + setup_ssr_page(server_base, "/").await; + let app = make_renderer().hydrate(); + + wait_for( + || output_element().inner_html().contains("Welcome"), + 5000, + "home page content after hydration", + ) + .await; + + app.destroy(); + yew::scheduler::flush().await; +} diff --git a/tools/ssr-e2e/Cargo.toml b/tools/ssr-e2e/Cargo.toml index 6e2d2076660..652a4e62970 100644 --- a/tools/ssr-e2e/Cargo.toml +++ b/tools/ssr-e2e/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true [dependencies] clap = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "time", "signal"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "time"] } reqwest = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/tools/ssr-e2e/src/main.rs b/tools/ssr-e2e/src/main.rs index 667733643c6..64f4bcfab04 100644 --- a/tools/ssr-e2e/src/main.rs +++ b/tools/ssr-e2e/src/main.rs @@ -92,7 +92,9 @@ async fn wait_for_server(url: &str, timeout: Duration) -> bool { fn shutdown_server(server: &mut Child) { #[cfg(unix)] if let Some(id) = server.id() { - let _ = unsafe { libc::kill(-(id as i32), libc::SIGTERM) }; + unsafe { + libc::kill(-(id as i32), libc::SIGTERM); + } return; } @@ -143,6 +145,7 @@ async fn main() -> ExitCode { let test_result = Command::new("cargo") .args(&cargo_args) .env("WASM_BINDGEN_TEST_NO_ORIGIN_ISOLATION", "1") + .env("WASM_BINDGEN_TEST_NO_STREAM", "1") .status() .await; diff --git a/tools/website-test/Cargo.toml b/tools/website-test/Cargo.toml index f8e6deb75d3..193a921bec9 100644 --- a/tools/website-test/Cargo.toml +++ b/tools/website-test/Cargo.toml @@ -18,11 +18,17 @@ serde = { workspace = true, features = ["derive"] } wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true weblog = "0.3.0" -yew = { path = "../../packages/yew/", features = ["ssr", "csr", "serde"] } +yew = { path = "../../packages/yew/", features = ["ssr", "csr", "hydration", "serde"] } yew-autoprops = "0.5.0" yew-router = { path = "../../packages/yew-router/" } tokio = { workspace = true, features = ["rt", "macros"] } +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +actix-web = { workspace = true } +axum = { workspace = true } +serde_json = { workspace = true } +yew-link = { path = "../../packages/yew-link/", features = ["actix", "axum"] } + [dev-dependencies.web-sys] workspace = true features = [ diff --git a/tools/website-test/build.rs b/tools/website-test/build.rs index b3b4b462306..217fe1e7714 100644 --- a/tools/website-test/build.rs +++ b/tools/website-test/build.rs @@ -183,7 +183,7 @@ impl Level { if should_combine_code_blocks(file)? { let res = combined_code_blocks(file)?; self.write_space(dst, level); - writeln!(dst, "/// ```rust, no_run")?; + writeln!(dst, "/// ```no_run")?; for line in res.lines() { self.write_space(dst, level); writeln!(dst, "/// {line}")?; diff --git a/website/docs/advanced-topics/server-side-rendering.mdx b/website/docs/advanced-topics/server-side-rendering.mdx index c31d1c668db..b2b5236fef6 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -128,6 +128,247 @@ suspended. With this approach, developers can build a client-agnostic, SSR-ready application with data fetching with very little effort. +### Low-level hooks + +Yew ships two low-level hooks for carrying server-computed state to the client: + +- **`use_prepared_state!`** runs an (optionally async) closure during SSR, serializes the result, and delivers it to the client during hydration. Ideal for fetching data that the component needs on first render. +- **`use_transitive_state!`** is similar, but the closure runs _after_ the component's SSR output is produced. Useful for collecting caches or aggregated state. + +Both use `bincode` + `base64` under the hood and are embedded in the HTML as `