diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 62ef8c6fa46..7dd10a983df 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -44,7 +44,7 @@ jobs: if: matrix.profile == 'dev' run: >- cargo hack clippy - -p yew -p yew-agent -p yew-router + -p yew -p yew-agent -p yew-router -p yew-link --feature-powerset --no-dev-deps --keep-going -- -D warnings @@ -53,7 +53,7 @@ jobs: if: matrix.profile == 'release' run: >- cargo hack clippy - -p yew -p yew-agent -p yew-router + -p yew -p yew-agent -p yew-router -p yew-link --feature-powerset --no-dev-deps --keep-going --release -- -D warnings diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml index 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 4654b274a92..933ae34a456 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,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -173,7 +440,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -204,7 +471,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -215,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" @@ -323,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" @@ -369,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" @@ -592,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" @@ -768,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]] @@ -1331,7 +1682,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.2.0", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -1352,7 +1703,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.3.0", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -1457,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" @@ -1468,7 +1838,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1514,7 +1884,7 @@ dependencies = [ "base64", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -1526,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]] @@ -1564,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" @@ -1581,7 +1962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1592,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" @@ -1625,8 +2012,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", + "h2 0.4.13", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -1643,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", @@ -1663,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", @@ -1839,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" @@ -2099,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" @@ -2184,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" @@ -2275,6 +2691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2414,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" @@ -2667,7 +3107,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2705,7 +3145,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2807,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" @@ -2854,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", @@ -3250,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" @@ -3286,38 +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", - "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]] @@ -3547,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]] @@ -3559,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" @@ -3625,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", ] @@ -3775,7 +4216,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "http-range-header", @@ -3923,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" @@ -4000,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" @@ -4051,7 +4504,7 @@ dependencies = [ "bytes", "futures-util", "headers", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -4313,12 +4766,15 @@ dependencies = [ name = "website-test" version = "0.1.0" dependencies = [ + "actix-web", + "axum", "derive_more", "glob", "gloo", "gloo-net 0.7.0", "js-sys", "serde", + "serde_json", "tokio", "wasm-bindgen", "wasm-bindgen-futures", @@ -4327,6 +4783,7 @@ dependencies = [ "yew", "yew-agent", "yew-autoprops", + "yew-link", "yew-router", ] @@ -4813,6 +5270,7 @@ dependencies = [ name = "yew-link" version = "0.1.0" dependencies = [ + "actix-web", "axum", "gloo-net 0.6.0", "lru", @@ -5010,6 +5468,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 18c98b23072..05b3cf7a5c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,3 +66,5 @@ thiserror = "2.0" bincode = { version = "2.0.0-rc.3", features = ["serde"] } reqwest = "0.13" axum = "0.8" +# 4.13 requires rustc > 1.85 +actix-web = "<4.13" diff --git a/examples/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/ssr_router/src/lib.rs b/examples/actix_ssr_router/src/lib.rs similarity index 100% rename from examples/ssr_router/src/lib.rs rename to examples/actix_ssr_router/src/lib.rs 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 98% rename from examples/ssr_router/Cargo.toml rename to examples/axum_ssr_router/Cargo.toml index 3573f127b8b..a164375d515 100644 --- a/examples/ssr_router/Cargo.toml +++ b/examples/axum_ssr_router/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ssr_router" +name = "axum_ssr_router" version = "0.1.0" edition = "2024" rust-version.workspace = true 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 92% rename from examples/ssr_router/src/bin/ssr_router_hydrate.rs rename to examples/axum_ssr_router/src/bin/ssr_router_hydrate.rs index 15fd7fbb162..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 ssr_router::{App, AppProps, LINK_ENDPOINT}; +use axum_ssr_router::{App, AppProps, LINK_ENDPOINT}; fn main() { #[cfg(target_arch = "wasm32")] 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 98% rename from examples/ssr_router/src/bin/ssr_router_server.rs rename to examples/axum_ssr_router/src/bin/ssr_router_server.rs index c562892877b..b6d93272f30 100644 --- a/examples/ssr_router/src/bin/ssr_router_server.rs +++ b/examples/axum_ssr_router/src/bin/ssr_router_server.rs @@ -10,21 +10,22 @@ use axum::handler::HandlerWithoutStateExt; use axum::http::Uri; use axum::response::IntoResponse; use axum::routing::{get, post}; +use axum_ssr_router::{ + LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, +}; use clap::Parser; use function_router::{Route, route_meta}; use futures::stream::{self, StreamExt}; use hyper::body::Incoming; use hyper_util::rt::TokioIo; use hyper_util::server; -use ssr_router::{ - LINK_ENDPOINT, LinkedAuthor, LinkedPost, LinkedPostMeta, ServerApp, ServerAppProps, -}; use tokio::net::TcpListener; use tower::Service; use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use yew::platform::Runtime; -use yew_link::{Resolver, ResolverProp, linked_state_handler}; +use yew_link::axum::linked_state_handler; +use yew_link::{Resolver, ResolverProp}; use yew_router::prelude::Routable; // We use jemalloc as it produces better performance. diff --git a/examples/axum_ssr_router/src/lib.rs b/examples/axum_ssr_router/src/lib.rs new file mode 100644 index 00000000000..c0103893e9c --- /dev/null +++ b/examples/axum_ssr_router/src/lib.rs @@ -0,0 +1,469 @@ +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/ssr_router/tests/e2e.rs b/examples/ssr_router/tests/e2e.rs deleted file mode 100644 index 39887013cf1..00000000000 --- a/examples/ssr_router/tests/e2e.rs +++ /dev/null @@ -1,199 +0,0 @@ -#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] - -use gloo::utils::document; -use ssr_e2e_harness::{ - clear_resource_timings, fetch_ssr_html, output_element, resource_request_count, setup_ssr_page, - wait_for, -}; -use ssr_router::{App, AppProps, LINK_ENDPOINT}; -use wasm_bindgen::JsCast; -use wasm_bindgen_test::*; -use yew::Renderer; - -wasm_bindgen_test_configure!(run_in_browser); - -const SERVER_BASE: &str = "http://127.0.0.1:8080"; - -fn endpoint() -> String { - format!("{SERVER_BASE}{LINK_ENDPOINT}") -} - -fn make_renderer() -> Renderer { - Renderer::::with_root_and_props( - output_element(), - AppProps { - endpoint: endpoint().into(), - }, - ) -} - -fn get_title_text() -> Option { - document() - .query_selector("h1.title") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) -} - -fn post_body_text() -> String { - output_element() - .query_selector(".section.container") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) - .unwrap_or_default() -} - -fn extract_text_from_html(html: &str, selector: &str) -> Option { - let container = document().create_element("div").unwrap(); - container.set_inner_html(html); - container - .query_selector(selector) - .ok() - .flatten() - .and_then(|el| el.text_content()) -} - -#[wasm_bindgen_test] -async fn ssr_hydration_and_client_navigation() { - // -- Part 1: Direct SSR visit to /posts/0 triggers no fetch to /api/link -- - - let ssr_html = fetch_ssr_html(SERVER_BASE, "/posts/0").await; - let ssr_title = extract_text_from_html(&ssr_html, "h1.title") - .expect("SSR HTML for /posts/0 should contain h1.title"); - let ssr_body = extract_text_from_html(&ssr_html, ".section.container").unwrap_or_default(); - - clear_resource_timings(); - - output_element().set_inner_html(&ssr_html); - ssr_e2e_harness::push_route("/posts/0"); - let app = make_renderer().hydrate(); - - wait_for( - || { - let html = output_element().inner_html(); - html.contains("

") && !html.contains("Loading post...") - }, - 5000, - "post page content after SSR hydration", - ) - .await; - - let link_fetches = resource_request_count(LINK_ENDPOINT); - let title = get_title_text(); - - assert_eq!( - link_fetches, 0, - "direct SSR visit to /posts/0 should not trigger any fetch to {LINK_ENDPOINT}" - ); - let title = title.expect("h1.title should be present on the SSR post page"); - assert!(!title.is_empty(), "SSR post title should not be empty"); - - // -- Part 2: Navigate to /posts within the same app, then to /posts/0 -- - - yew::scheduler::flush().await; - - clear_resource_timings(); - - let posts_link: web_sys::HtmlElement = output_element() - .query_selector("a.navbar-item[href='/posts']") - .unwrap() - .expect("Posts navbar link should exist") - .dyn_into() - .unwrap(); - posts_link.click(); - yew::scheduler::flush().await; - - wait_for( - || { - document() - .query_selector("a.title.is-block") - .ok() - .flatten() - .is_some() - && get_title_text().as_deref() == Some("Posts") - }, - 15000, - "posts list after client-side navigation to /posts", - ) - .await; - - clear_resource_timings(); - - wait_for( - || { - output_element() - .query_selector("a.title.is-block[href='/posts/0']") - .ok() - .flatten() - .is_some() - }, - 15000, - "post 0 card link on posts list", - ) - .await; - - let post_link: web_sys::HtmlElement = output_element() - .query_selector("a.title.is-block[href='/posts/0']") - .unwrap() - .unwrap() - .dyn_into() - .unwrap(); - post_link.click(); - yew::scheduler::flush().await; - - wait_for( - || { - document() - .query_selector("h2.subtitle") - .ok() - .flatten() - .map(|el| el.text_content().unwrap_or_default()) - .is_some_and(|text| text.starts_with("by ")) - }, - 15000, - "post page content after client-side navigation to /posts/0", - ) - .await; - - // -- Part 3: Verify fetch happened and content matches SSR -- - - let nav_link_fetches = resource_request_count(LINK_ENDPOINT); - let nav_title = get_title_text(); - let nav_body = post_body_text(); - - assert!( - nav_link_fetches >= 1, - "client-side navigation to /posts/0 should trigger at least one fetch to {LINK_ENDPOINT}, \ - got {nav_link_fetches}" - ); - - let nav_title = nav_title.expect("h1.title should be present after client-side navigation"); - assert_eq!( - ssr_title, nav_title, - "post title should match between SSR and client-side navigation" - ); - assert_eq!( - ssr_body, nav_body, - "post body should match between SSR and client-side navigation" - ); - - app.destroy(); - yew::scheduler::flush().await; -} - -#[wasm_bindgen_test] -async fn hydrate_home() { - setup_ssr_page(SERVER_BASE, "/").await; - let app = make_renderer().hydrate(); - - wait_for( - || output_element().inner_html().contains("Welcome"), - 5000, - "home page content after hydration", - ) - .await; - - app.destroy(); - yew::scheduler::flush().await; -} diff --git a/packages/yew-link/Cargo.toml b/packages/yew-link/Cargo.toml index a9a99149b32..6b801a6c854 100644 --- a/packages/yew-link/Cargo.toml +++ b/packages/yew-link/Cargo.toml @@ -20,12 +20,14 @@ wasm-bindgen-futures = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { workspace = true, optional = true } +actix-web = { workspace = true, optional = true } [features] default = [] ssr = ["yew/ssr"] hydration = ["yew/hydration"] axum = ["dep:axum"] +actix = ["dep:actix-web"] [lints] workspace = true diff --git a/packages/yew-link/src/lib.rs b/packages/yew-link/src/lib.rs index c3d412fd881..f889f5291c9 100644 --- a/packages/yew-link/src/lib.rs +++ b/packages/yew-link/src/lib.rs @@ -289,7 +289,7 @@ impl Eq for CacheKey {} fn eq_inputs(a: &dyn Any, b: &dyn Any) -> bool { a.downcast_ref::() .zip(b.downcast_ref::()) - .map_or(false, |(a, b)| a == b) + .is_some_and(|(a, b)| a == b) } #[cfg(target_arch = "wasm32")] @@ -511,6 +511,7 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult().expect("use_linked_state requires a LinkProvider"); + #[cfg(any(feature = "ssr", target_arch = "wasm32"))] type Prepared = Result>; #[cfg(feature = "ssr")] @@ -540,9 +541,7 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult, T::Input>( - input, - )?; + let _ = input; Ok(LinkedStateHandle { result: Err(LinkError::Internal( "yew-link requires the `ssr` feature (server) or a wasm32 target (client)".into(), @@ -678,51 +677,8 @@ pub fn use_linked_state(input: T::Input) -> SuspensionResult(|id| async move { db::get_post(id).await }) - /// ); - /// - /// let app = axum::Router::new() - /// .route("/api/link", axum::routing::post(linked_state_handler)) - /// .with_state(resolver); - /// ``` - pub async fn linked_state_handler( - State(resolver): State>, - Json(req): Json, - ) -> impl IntoResponse { - match resolver.resolve_request(&req).await { - Ok(val) => ( - StatusCode::OK, - Json(LinkResponse { - ok: Some(val), - error: None, - }), - ), - Err(err_val) => ( - StatusCode::UNPROCESSABLE_ENTITY, - Json(LinkResponse { - ok: None, - error: Some(err_val), - }), - ), - } - } -} +#[cfg(all(not(target_arch = "wasm32"), any(feature = "axum", feature = "actix")))] +mod services; -#[cfg(all(feature = "axum", not(target_arch = "wasm32")))] -pub use service::linked_state_handler; +#[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/tools/ssr-e2e-harness/Cargo.toml b/tools/ssr-e2e-harness/Cargo.toml index 5318504a890..3c1ec539385 100644 --- a/tools/ssr-e2e-harness/Cargo.toml +++ b/tools/ssr-e2e-harness/Cargo.toml @@ -8,3 +8,4 @@ rust-version.workspace = true gloo = { workspace = true, features = ["futures"] } web-sys = { workspace = true, features = ["Performance", "PerformanceEntry"] } wasm-bindgen = { workspace = true } +yew = { path = "../../packages/yew", features = ["csr", "hydration", "test"] } diff --git a/tools/ssr-e2e-harness/src/lib.rs b/tools/ssr-e2e-harness/src/lib.rs index 59f4dda5146..b36dd9a0fef 100644 --- a/tools/ssr-e2e-harness/src/lib.rs +++ b/tools/ssr-e2e-harness/src/lib.rs @@ -2,6 +2,8 @@ use std::time::Duration; use gloo::utils::document; use wasm_bindgen::prelude::*; +use yew::Renderer; +use yew::html::BaseComponent; /// Returns the `
` element used by wasm-bindgen-test as the /// test output container. @@ -82,3 +84,202 @@ pub fn resource_request_count(needle: &str) -> u32 { pub fn clear_resource_timings() { performance().clear_resource_timings(); } + +/// Returns the text content of the first `

` 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/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 ea544b0d34a..0189f1e2d4a 100644 --- a/website/docs/advanced-topics/server-side-rendering.mdx +++ b/website/docs/advanced-topics/server-side-rendering.mdx @@ -148,7 +148,10 @@ The `yew-link` crate provides a higher-level abstraction that unifies all three 3. **Wrap** your app in ``. 4. **Call** `use_linked_state::(input)` in any component. -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } use yew_link::{linked_state, LinkedState}; #[derive(Clone, Serialize, Deserialize)] @@ -171,7 +174,20 @@ The macro generates the `LinkedState` and (server-only) `LinkedStateResolve` tra If `resolve` can fail, declare `type Error`: -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } #[linked_state] impl LinkedState for Post { type Context = DbPool; @@ -199,22 +215,147 @@ Multiple components requesting the same `(T, Input)` concurrently share a single #### Server setup -```rust ,ignore -use yew_link::{Resolver, linked_state_handler}; +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# fn main() { +# let db_pool = DbPool; +use yew_link::{Resolver, axum::linked_state_handler}; let resolver = Arc::new( Resolver::new() .register_linked::(db_pool.clone()) ); -let app = axum::Router::new() - .route("/api/link", axum::routing::post(linked_state_handler)) - .with_state(resolver); +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } +``` + +The example above uses the `axum` feature. `yew-link` also ships an `actix` +feature that exposes the same handler under `yew_link::actix::linked_state_handler`: + +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::(db_pool.clone()) +); + +HttpServer::new(move || { + App::new() + .app_data(resolver.clone()) + .route("/api/link", post().to(linked_state_handler)) +}) +.bind(("0.0.0.0", 8080))? +.run() +.await +# } +# fn main() {} ``` +
+Why `actix-web` is pinned to 4.12, and how to implement a handler for other server frameworks + +The `actix` feature is pinned to `actix-web 4.12.x` because Yew's MSRV is +`1.85`, while `actix-web 4.13` and newer require `rustc 1.88`. This pin lives +inside `yew-link` only so the rest of the workspace stays buildable on the +supported toolchain; it does not constrain what version of `actix-web` you +use elsewhere in your own application. + +If you need a newer `actix-web` (or any other web framework) and do not want +to enable the bundled feature, the handler is small enough to inline +yourself. The only public surface you need is `Resolver::resolve_request` +and the wire-format type `LinkRequest`: + +```rust ,ignore-wasm32 +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data, + Json(req): Json, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +The same shape works for `axum`, `warp`, `rocket`, or any framework that can +deserialize JSON into `LinkRequest`, call an async function, and serialize a +JSON response. Construct the response wire format with `serde_json::json!` +to keep yew-link's internal `LinkResponse` type out of your dependency +surface. + +
+ #### Component usage -```rust ,ignore +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] use yew_link::{use_linked_state, LinkProvider}; #[component] @@ -226,7 +367,7 @@ fn PostPage(props: &PostPageProps) -> HtmlResult { During SSR the state is resolved locally via the `Resolver` and embedded in the HTML through `use_prepared_state`. On hydration the client reads the embedded state with zero network requests. On subsequent client-side navigations the hook fetches from the `LinkProvider`'s endpoint URL automatically. -See the [`ssr_router` example](https://github.com/yewstack/yew/tree/master/examples/ssr_router) for a full working demo. +See the [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) and [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) examples for full working demos. ## Rendering `` Tags @@ -238,7 +379,7 @@ load. it has no access to ``. Head tags must therefore be generated **on the server, outside of Yew**, and spliced into the HTML template before it is sent to the client. -The [`ssr_router` example](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) demonstrates this pattern: the server recognizes the +The [`axum_ssr_router` example](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) demonstrates this pattern: the server recognizes the route from the request URL, generates the appropriate `` and `<meta>` tags, and injects them into the Trunk-generated `index.html` before `</head>`. @@ -295,7 +436,7 @@ until `rendered()` method is called. ## Example -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; @@ -314,7 +455,8 @@ fn main() { ``` Example: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -Example: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +Example: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +Example: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## Single thread mode diff --git a/website/docs/concepts/html/fragments.mdx b/website/docs/concepts/html/fragments.mdx index 21a6ca3388a..2612a041f8e 100644 --- a/website/docs/concepts/html/fragments.mdx +++ b/website/docs/concepts/html/fragments.mdx @@ -27,7 +27,7 @@ html! { <TabItem value="Invalid" label="Invalid"> -```rust, compile_fail +```rust ,compile_fail use yew::prelude::*; // error: only one root html element allowed diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index 6496ca5962c..0e78401625c 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -84,13 +84,242 @@ Yewは、`<Suspense />` を使用してこの問題を解決する異なるア この方法により、開発者はサーバーサイドレンダリングに対応したクライアント非依存のアプリケーションを簡単に構築し、データ取得を行うことができます。 +### 低レベルフック + +Yew は、サーバー側で計算した状態をクライアントへ運ぶための低レベルフックを 2 つ提供しています: + +- **`use_prepared_state!`** は SSR 中に(必要であれば async な)クロージャを実行し、結果をシリアライズして、ハイドレーション中にクライアントへ届けます。コンポーネントが最初のレンダリング時に必要とするデータの取得に向いています。 +- **`use_transitive_state!`** も同様ですが、クロージャはコンポーネントの SSR 出力が生成された _後_ に実行されます。キャッシュや集約状態の収集に向いています。 + +どちらも内部で `bincode` と `base64` を使い、HTML に `<script>` タグとして埋め込まれます。 + +### `yew-link`:統一されたデータ取得 + +低レベルフックは **初回のページ読み込み**(SSR からハイドレーションまで)を扱いますが、ハイドレーション後のクライアントサイドナビゲーションには別のデータ取得経路(例えば `use_future_with` と HTTP クライアントの組み合わせ)が必要です。これは同じデータロジックを 2 回書くことを意味します。 + +`yew-link` クレートは、これら 3 つの経路(SSR、ハイドレーション、クライアントサイドナビゲーション)を 1 つのフックの背後で統一する高水準の抽象を提供します: + +1. `#[linked_state]` 属性マクロで自分のデータ型を **定義** します。 +2. サーバー側で resolver を **登録** します。 +3. アプリを `<LinkProvider>` で **包みます**。 +4. 任意のコンポーネントから `use_linked_state::<MyData>(input)` を **呼び出します**。 + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } +use yew_link::{linked_state, LinkedState}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Post { pub title: String, pub body: String } + +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + + async fn resolve(ctx: &DbPool, id: &u32) -> Self { + ctx.get_post(*id).await + } +} +``` + +このマクロは `LinkedState` と(サーバー専用の)`LinkedStateResolve` トレイト実装を生成します。`resolve` の本体は WASM バンドルから自動的に取り除かれます。 + +#### 型付きエラー + +`resolve` が失敗する可能性がある場合は、`type Error` を宣言します: + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + type Error = ApiError; + + async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> { + ctx.get_post(*id).await.map_err(ApiError::from) + } +} +``` + +`type Error` を省略した場合、エラー型は実体を持たない `Never` 型がデフォルトとなり、`resolve` の本体は自動的に `Ok(…)` で包まれます。 + +`use_linked_state` は `SuspensionResult<LinkedStateHandle<T>>` を返します。外側はサスペンス(読み込み中)を表します。ハンドルは以下を提供します: + +- **`.data()`** は解決済みの `Rc<T>` を返します。エラーがあれば panic します。 +- **`.as_result()`** は内部の `Result<Rc<T>, LinkError<T::Error>>` を借用するので、パターンマッチで扱えます。 +- **`.refresh()`** はバックグラウンドで再取得を起動しつつ、それまでの(古い)値を表示し続けます(stale-while-revalidate)。 +- **`.is_refreshing()`** はバックグラウンドのリフレッシュが進行中の間 `true` を返すので、古いデータの隣にローディングインジケータを表示できます。 + +`LinkError` は、アプリケーションレベルのエラー(`LinkError::Resolve`)とインフラ層の障害(`LinkError::Internal`)を区別します。 + +複数のコンポーネントが同じ `(T, Input)` を同時に要求した場合、それらは進行中の 1 つのリクエストを自動的に共有します。 + +#### サーバー側の設定 + +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# fn main() { +# let db_pool = DbPool; +use yew_link::{Resolver, axum::linked_state_handler}; + +let resolver = Arc::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } +``` + +上の例では `axum` 機能を使っています。`yew-link` は `actix` 機能も提供しており、同じハンドラを `yew_link::actix::linked_state_handler` 経由で公開します: + +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +HttpServer::new(move || { + App::new() + .app_data(resolver.clone()) + .route("/api/link", post().to(linked_state_handler)) +}) +.bind(("0.0.0.0", 8080))? +.run() +.await +# } +# fn main() {} +``` + +<details> +<summary>なぜ `actix-web` が 4.12 に固定されているのか、他のサーバーフレームワーク向けにハンドラを実装する方法</summary> + +`actix` 機能は `actix-web 4.12.x` に固定されています。これは Yew の MSRV が `1.85` であるのに対し、`actix-web 4.13` 以降は `rustc 1.88` を必要とするためです。このバージョン固定は `yew-link` の内部にだけ存在し、ワークスペースの他の部分がサポートされたツールチェインでビルドできるようにすることが目的です。あなた自身のアプリケーションの他の場所でどのバージョンの `actix-web` を使うかには影響しません。 + +より新しい `actix-web`(あるいは他の web フレームワーク)が必要で、同梱の機能を有効にしたくない場合、ハンドラ自体は十分に小さいので自分でインライン実装できます。必要となる公開 API は `Resolver::resolve_request` と、ワイヤーフォーマットの型 `LinkRequest` だけです: + +```rust ,ignore-wasm32 +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data<Resolver>, + Json(req): Json<LinkRequest>, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +同じ書き方は `axum`、`warp`、`rocket`、あるいは JSON を `LinkRequest` にデシリアライズし、async 関数を呼び出して JSON レスポンスをシリアライズできる任意のフレームワークで動作します。レスポンスのワイヤーフォーマットは `serde_json::json!` で構築すると、yew-link 内部の `LinkResponse` 型を依存範囲に含めずに済みます。 + +</details> + +#### コンポーネント内での利用 + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] +use yew_link::{use_linked_state, LinkProvider}; + +#[component] +fn PostPage(props: &PostPageProps) -> HtmlResult { + let post = use_linked_state::<Post>(props.id)?.data(); + Ok(html! { <h1>{ &post.title }</h1> }) +} +``` + +SSR 中、状態は `Resolver` を通じてサーバー側でローカルに解決され、`use_prepared_state` を介して HTML に埋め込まれます。ハイドレーション時、クライアントは埋め込まれた状態をネットワークリクエストなしに直接読み取ります。その後のクライアントサイドナビゲーションでは、フックは `LinkProvider` の `endpoint` URL から自動的にデータを取得します。 + +完全に動作するデモは [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) と [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) のサンプルを参照してください。 + ## `<head>` タグのレンダリング SSR でよく必要とされるのは、クローラーやソーシャルプレビューが最初のロード時に正しいメタデータを参照できるよう、動的な `<head>` コンテンツ(`<title>`、`<meta>` など)をレンダリングすることです。 `ServerRenderer` はコンポーネントツリー(通常はドキュメントの body 部分)のみをレンダリングし、`<head>` にはアクセスできません。そのため、head タグは **Yew の外部でサーバー側に**生成し、クライアントに送信する前に HTML テンプレートに埋め込む必要があります。 -[`ssr_router` サンプル](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) はこのパターンを示しています:サーバーはリクエスト URL からルートを判別し、適切な `<title>` および `<meta>` タグを生成して、Trunk が生成した `index.html` の `</head>` の前に挿入します。 +[`axum_ssr_router` サンプル](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) はこのパターンを示しています:サーバーはリクエスト URL からルートを判別し、適切な `<title>` および `<meta>` タグを生成して、Trunk が生成した `index.html` の `</head>` の前に挿入します。 :::info @@ -120,7 +349,7 @@ SSR出力(静的HTML)をブラウザが初期レンダリングした後、 ## 例 -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; @@ -138,7 +367,8 @@ fn main() { ``` 例: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -例: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +例: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +例: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## シングルスレッドモード diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index f19269b66de..7f4f89ad759 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -84,13 +84,242 @@ Yew 采用了一种不同的方法,通过 `<Suspense />` 来解决这个问题 通过这种方法,开发人员可以轻松构建一个准备好进行服务端渲染的、与客户端无关的应用程序,并进行数据获取。 +### 低层钩子 + +Yew 提供了两个低层钩子,用于将服务端计算出的状态传递到客户端: + +- **`use_prepared_state!`** 在 SSR 期间运行一个(可选 async 的)闭包,将结果序列化,并在水合期间传递到客户端。适合获取组件首次渲染所需的数据。 +- **`use_transitive_state!`** 类似,但闭包会在组件的 SSR 输出生成 _之后_ 才运行。适合收集缓存或汇总状态。 + +两者底层都使用 `bincode` 加 `base64`,并以 `<script>` 标签的形式嵌入到 HTML 中。 + +### `yew-link`:统一的数据获取 + +低层钩子能处理 **首次页面加载**(从 SSR 到水合),但水合之后的客户端导航需要一条独立的数据获取路径(例如 `use_future_with` 加上一个 HTTP 客户端)。这意味着同一份数据逻辑要写两遍。 + +`yew-link` crate 提供了一个更高层的抽象,将所有三条路径(SSR、水合和客户端导航)统一在同一个钩子背后: + +1. 使用 `#[linked_state]` 属性宏 **定义** 你的数据类型。 +2. 在服务端 **注册** 一个 resolver。 +3. 用 `<LinkProvider>` **包裹** 你的应用。 +4. 在任意组件中 **调用** `use_linked_state::<MyData>(input)`。 + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } +use yew_link::{linked_state, LinkedState}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Post { pub title: String, pub body: String } + +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + + async fn resolve(ctx: &DbPool, id: &u32) -> Self { + ctx.get_post(*id).await + } +} +``` + +该宏会生成 `LinkedState` 以及(仅服务端的)`LinkedStateResolve` trait 实现。`resolve` 函数体会自动从 WASM 包中剥离。 + +#### 类型化错误 + +如果 `resolve` 可能失败,可以声明 `type Error`: + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + type Error = ApiError; + + async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> { + ctx.get_post(*id).await.map_err(ApiError::from) + } +} +``` + +省略 `type Error` 时,错误类型默认为不可实例化的 `Never`,并且 `resolve` 函数体会被自动包装在 `Ok(…)` 中。 + +`use_linked_state` 返回 `SuspensionResult<LinkedStateHandle<T>>`。外层用于挂起(加载中)。该句柄提供: + +- **`.data()`** 返回解析后的 `Rc<T>`,遇到错误时会 panic。 +- **`.as_result()`** 借用底层的 `Result<Rc<T>, LinkError<T::Error>>`,方便进行模式匹配。 +- **`.refresh()`** 触发后台重新拉取,同时仍然显示之前(旧)的值(stale-while-revalidate)。 +- **`.is_refreshing()`** 当后台刷新进行中时返回 `true`,便于在旧数据旁边显示加载指示器。 + +`LinkError` 区分了应用层错误(`LinkError::Resolve`)与基础设施错误(`LinkError::Internal`)。 + +如果多个组件并发请求相同的 `(T, Input)`,它们会自动共用同一个进行中的请求。 + +#### 服务端设置 + +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# fn main() { +# let db_pool = DbPool; +use yew_link::{Resolver, axum::linked_state_handler}; + +let resolver = Arc::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } +``` + +上面的例子使用了 `axum` 特性。`yew-link` 也提供了一个 `actix` 特性,将相同的 handler 暴露在 `yew_link::actix::linked_state_handler` 之下: + +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +HttpServer::new(move || { + App::new() + .app_data(resolver.clone()) + .route("/api/link", post().to(linked_state_handler)) +}) +.bind(("0.0.0.0", 8080))? +.run() +.await +# } +# fn main() {} +``` + +<details> +<summary>为什么 `actix-web` 被锁定在 4.12,以及如何为其他服务器框架实现 handler</summary> + +`actix` 特性被锁定在 `actix-web 4.12.x`,因为 Yew 的 MSRV 为 `1.85`,而 `actix-web 4.13` 及更高版本要求 `rustc 1.88`。这一版本约束仅存在于 `yew-link` 内部,目的是让 workspace 的其他部分仍能在受支持的工具链上构建;它不会影响你在自己应用程序的其他地方使用哪个版本的 `actix-web`。 + +如果你需要更新版本的 `actix-web`(或者任何其他 web 框架),并且不想启用内置的特性,这个 handler 足够小,完全可以自己内联实现。你需要用到的全部公开 API 只有 `Resolver::resolve_request` 以及表示传输格式的 `LinkRequest` 类型: + +```rust ,ignore-wasm32 +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data<Resolver>, + Json(req): Json<LinkRequest>, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +同样的写法适用于 `axum`、`warp`、`rocket`,或任何能够将 JSON 反序列化为 `LinkRequest`、调用一个 async 函数并将结果序列化为 JSON 响应的框架。使用 `serde_json::json!` 构造响应的传输格式,可以避免让 yew-link 内部的 `LinkResponse` 类型出现在你的依赖范围中。 + +</details> + +#### 组件中的使用 + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] +use yew_link::{use_linked_state, LinkProvider}; + +#[component] +fn PostPage(props: &PostPageProps) -> HtmlResult { + let post = use_linked_state::<Post>(props.id)?.data(); + Ok(html! { <h1>{ &post.title }</h1> }) +} +``` + +在 SSR 期间,状态会通过 `Resolver` 在本地解析,并通过 `use_prepared_state` 嵌入到 HTML 中。水合时客户端会直接读取嵌入的状态,无需任何网络请求。在之后的客户端导航中,该钩子会自动从 `LinkProvider` 的 endpoint URL 拉取数据。 + +参见 [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) 与 [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) 示例查看完整可运行的演示。 + ## 渲染 `<head>` 标签 SSR 中的一个常见需求是渲染动态 `<head>` 内容(例如 `<title>`、`<meta>`),使爬虫和社交预览在首次加载时能看到正确的元数据。 `ServerRenderer` 只渲染组件树(通常对应文档的 body 部分),无法访问 `<head>`。因此,head 标签必须**在服务端、Yew 之外**生成,并在发送给客户端之前拼接到 HTML 模板中。 -[`ssr_router` 示例](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) 演示了这一模式:服务端从请求 URL 识别路由,生成适当的 `<title>` 和 `<meta>` 标签,并将它们注入到 Trunk 生成的 `index.html` 的 `</head>` 之前。 +[`axum_ssr_router` 示例](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) 演示了这一模式:服务端从请求 URL 识别路由,生成适当的 `<title>` 和 `<meta>` 标签,并将它们注入到 Trunk 生成的 `index.html` 的 `</head>` 之前。 :::info @@ -120,7 +349,7 @@ SSR 中的一个常见需求是渲染动态 `<head>` 内容(例如 `<title>` ## 示例 -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; @@ -138,7 +367,8 @@ fn main() { ``` 示例: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -示例: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +示例: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +示例: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## 单线程模式 diff --git a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx index d407744eb4f..d1e1a7a7f27 100644 --- a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx +++ b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/advanced-topics/server-side-rendering.mdx @@ -84,13 +84,242 @@ Yew 採用了不同的方法,透過 `<Suspense />` 來解決這個問題。 透過這種方法,開發人員可以輕鬆建立一個準備好進行服務端渲染的、與客戶端無關的應用程序,並進行資料擷取。 +### 低階鉤子 + +Yew 提供了兩個低階鉤子,用於將伺服器端計算出的狀態傳遞到客戶端: + +- **`use_prepared_state!`** 在 SSR 期間執行一個(可選 async 的)閉包,將結果序列化,並在水合期間傳遞到客戶端。適合取得元件首次渲染時所需的資料。 +- **`use_transitive_state!`** 類似,但閉包會在元件的 SSR 輸出產生 _之後_ 才執行。適合收集快取或彙總狀態。 + +兩者底層都使用 `bincode` 加 `base64`,並以 `<script>` 標籤的形式嵌入到 HTML 中。 + +### `yew-link`:統一的資料取得 + +低階鉤子能處理 **首次頁面載入**(從 SSR 到水合),但水合之後的客戶端導覽需要一條獨立的資料取得路徑(例如 `use_future_with` 加上一個 HTTP 客戶端)。這意味著同一份資料邏輯要寫兩次。 + +`yew-link` crate 提供了一個更高層的抽象,將這三條路徑(SSR、水合與客戶端導覽)統一在同一個鉤子之下: + +1. 使用 `#[linked_state]` 屬性巨集 **定義** 你的資料型別。 +2. 在伺服器端 **註冊** 一個 resolver。 +3. 用 `<LinkProvider>` **包裹** 你的應用程式。 +4. 在任意元件中 **呼叫** `use_linked_state::<MyData>(input)`。 + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# pub struct DbPool; +# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } } +use yew_link::{linked_state, LinkedState}; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Post { pub title: String, pub body: String } + +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + + async fn resolve(ctx: &DbPool, id: &u32) -> Self { + ctx.get_post(*id).await + } +} +``` + +這個巨集會產生 `LinkedState` 以及(僅伺服器端的)`LinkedStateResolve` trait 實作。`resolve` 函式本體會自動從 WASM 包中移除。 + +#### 型別化錯誤 + +如果 `resolve` 可能失敗,可以宣告 `type Error`: + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone, Debug, Serialize, Deserialize)] +# pub struct ApiError; +# impl std::fmt::Display for ApiError { +# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") } +# } +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +#[linked_state] +impl LinkedState for Post { + type Context = DbPool; + type Input = u32; + type Error = ApiError; + + async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> { + ctx.get_post(*id).await.map_err(ApiError::from) + } +} +``` + +省略 `type Error` 時,錯誤型別預設為無法實例化的 `Never`,並且 `resolve` 函式本體會被自動包裝在 `Ok(…)` 中。 + +`use_linked_state` 回傳 `SuspensionResult<LinkedStateHandle<T>>`。外層用於 suspense(載入中)。該 handle 提供: + +- **`.data()`** 回傳解析後的 `Rc<T>`,遇到錯誤時會 panic。 +- **`.as_result()`** 借用底層的 `Result<Rc<T>, LinkError<T::Error>>`,方便進行模式比對。 +- **`.refresh()`** 觸發背景重新拉取,同時仍然顯示先前(舊)的值(stale-while-revalidate)。 +- **`.is_refreshing()`** 當背景重新整理進行中時回傳 `true`,便於在舊資料旁邊顯示載入指示器。 + +`LinkError` 區分了應用層錯誤(`LinkError::Resolve`)與基礎設施錯誤(`LinkError::Internal`)。 + +如果多個元件並發請求相同的 `(T, Input)`,它們會自動共用同一個進行中的請求。 + +#### 伺服器端設定 + +```rust ,ignore-wasm32 +# use std::sync::Arc; +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# fn main() { +# let db_pool = DbPool; +use yew_link::{Resolver, axum::linked_state_handler}; + +let resolver = Arc::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +let app: axum::Router = axum::Router::new().route( + "/api/link", + axum::routing::post(linked_state_handler).with_state(resolver), +); +# let _ = app; +# } +``` + +上面的範例使用了 `axum` 特性。`yew-link` 也提供一個 `actix` 特性,將相同的 handler 暴露在 `yew_link::actix::linked_state_handler` 之下: + +```rust ,no_run,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew_link::{linked_state, LinkedState}; +# #[derive(Clone)] +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# async fn run() -> std::io::Result<()> { +# let db_pool = DbPool; +use actix_web::{App, HttpServer, web::{Data, post}}; +use yew_link::{Resolver, actix::linked_state_handler}; + +let resolver = Data::new( + Resolver::new() + .register_linked::<Post>(db_pool.clone()) +); + +HttpServer::new(move || { + App::new() + .app_data(resolver.clone()) + .route("/api/link", post().to(linked_state_handler)) +}) +.bind(("0.0.0.0", 8080))? +.run() +.await +# } +# fn main() {} +``` + +<details> +<summary>為什麼 `actix-web` 被鎖定在 4.12,以及如何為其他伺服器框架實作 handler</summary> + +`actix` 特性被鎖定在 `actix-web 4.12.x`,因為 Yew 的 MSRV 為 `1.85`,而 `actix-web 4.13` 及更新的版本要求 `rustc 1.88`。這個版本約束僅存在於 `yew-link` 內部,目的是讓 workspace 的其他部分仍能在受支援的工具鏈上建構;它不會影響你在自己應用程式的其他地方使用哪個版本的 `actix-web`。 + +如果你需要更新版本的 `actix-web`(或其他任何 web 框架),並且不想啟用內建的特性,這個 handler 已經足夠小,完全可以自己內聯實作。你需要用到的全部公開 API 只有 `Resolver::resolve_request` 以及表示傳輸格式的 `LinkRequest` 型別: + +```rust ,ignore-wasm32 +use actix_web::HttpResponse; +use actix_web::web::{Data, Json}; +use serde_json::json; +use yew_link::{LinkRequest, Resolver}; + +pub async fn linked_state_handler( + resolver: Data<Resolver>, + Json(req): Json<LinkRequest>, +) -> HttpResponse { + match resolver.resolve_request(&req).await { + Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })), + Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })), + } +} +``` + +同樣的寫法適用於 `axum`、`warp`、`rocket`,或任何能夠將 JSON 反序列化為 `LinkRequest`、呼叫一個 async 函式並將結果序列化為 JSON 回應的框架。使用 `serde_json::json!` 建構回應的傳輸格式,可以避免讓 yew-link 內部的 `LinkResponse` 型別出現在你的依賴範圍中。 + +</details> + +#### 元件中的使用 + +```rust ,ignore-wasm32 +# use serde::{Serialize, Deserialize}; +# use yew::prelude::*; +# use yew_link::{linked_state, LinkedState}; +# pub struct DbPool; +# impl DbPool { +# async fn get_post(&self, _id: u32) -> Post { unreachable!() } +# } +# #[derive(Clone, Serialize, Deserialize)] +# pub struct Post { pub title: String, pub body: String } +# #[linked_state] +# impl LinkedState for Post { +# type Context = DbPool; +# type Input = u32; +# async fn resolve(ctx: &DbPool, id: &u32) -> Self { +# ctx.get_post(*id).await +# } +# } +# #[derive(Properties, PartialEq)] +# pub struct PostPageProps { pub id: u32 } +#[allow(unused_imports)] +use yew_link::{use_linked_state, LinkProvider}; + +#[component] +fn PostPage(props: &PostPageProps) -> HtmlResult { + let post = use_linked_state::<Post>(props.id)?.data(); + Ok(html! { <h1>{ &post.title }</h1> }) +} +``` + +在 SSR 期間,狀態會透過 `Resolver` 在伺服器端本地解析,並透過 `use_prepared_state` 嵌入到 HTML 中。水合時客戶端會直接讀取嵌入的狀態,無需任何網路請求。在後續的客戶端導覽中,該鉤子會自動從 `LinkProvider` 的 endpoint URL 取得資料。 + +可以參考 [`axum_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) 與 [`actix_ssr_router`](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) 範例,查看完整可運作的演示。 + ## 渲染 `<head>` 標籤 SSR 中的一個常見需求是渲染動態 `<head>` 內容(例如 `<title>`、`<meta>`),使爬蟲和社群預覽在首次載入時能看到正確的中繼資料。 `ServerRenderer` 只渲染元件樹(通常對應文件的 body 部分),無法存取 `<head>`。因此,head 標籤必須**在伺服器端、Yew 之外**產生,並在傳送給客戶端之前拼接到 HTML 範本中。 -[`ssr_router` 範例](https://github.com/yewstack/yew/blob/master/examples/ssr_router/src/bin/ssr_router_server.rs) 展示了這個模式:伺服器從請求 URL 識別路由,產生適當的 `<title>` 和 `<meta>` 標籤,並將它們注入到 Trunk 產生的 `index.html` 的 `</head>` 之前。 +[`axum_ssr_router` 範例](https://github.com/yewstack/yew/blob/master/examples/axum_ssr_router/src/bin/ssr_router_server.rs) 展示了這個模式:伺服器從請求 URL 識別路由,產生適當的 `<title>` 和 `<meta>` 標籤,並將它們注入到 Trunk 產生的 `index.html` 的 `</head>` 之前。 :::info @@ -120,7 +349,7 @@ SSR 中的一個常見需求是渲染動態 `<head>` 內容(例如 `<title>` ## 範例 -```rust ,ignore +```rust ,no_run use yew::prelude::*; use yew::Renderer; @@ -138,7 +367,8 @@ fn main() { ``` 範例: [simple_ssr](https://github.com/yewstack/yew/tree/master/examples/simple_ssr) -範例: [ssr_router](https://github.com/yewstack/yew/tree/master/examples/ssr_router) +範例: [axum_ssr_router](https://github.com/yewstack/yew/tree/master/examples/axum_ssr_router) +範例: [actix_ssr_router](https://github.com/yewstack/yew/tree/master/examples/actix_ssr_router) ## 單線程模式