diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..464189e01 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# All of these variables are used in the `openid_connect_demo` example, they are set here for the CI to work, they are set here because as stated here for now: `https://doc.rust-lang.org/cargo/reference/config.html` the .cargo/config.toml of the inner workspaces are not read when being invoked from the root workspace. +[env] +DIOXUS_FRONT_ISSUER_URL = "" +DIOXUS_FRONT_CLIENT_ID = "" +DIOXUS_FRONT_URL = "" diff --git a/.github/workflows/docs stable.yml b/.github/workflows/docs stable.yml index 642216f0f..523a24067 100644 --- a/.github/workflows/docs stable.yml +++ b/.github/workflows/docs stable.yml @@ -33,7 +33,7 @@ jobs: # cd fermi && mdbook build -d ../nightly/fermi && cd .. - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.4.3 + uses: JamesIves/github-pages-deploy-action@v4.5.0 with: branch: gh-pages # The branch the action should deploy to. folder: docs/nightly # The folder the action should deploy. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 88542aea8..7701209c1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,7 +39,7 @@ jobs: # cd fermi && mdbook build -d ../nightly/fermi && cd .. - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.4.3 + uses: JamesIves/github-pages-deploy-action@v4.5.0 with: branch: gh-pages # The branch the action should deploy to. folder: docs/nightly # The folder the action should deploy. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b1b1b5c22..8001af650 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -130,7 +130,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ilammy/setup-nasm@v1 - - name: install stable uses: dtolnay/rust-toolchain@master with: @@ -146,6 +145,11 @@ jobs: workspaces: core -> ../target save-if: ${{ matrix.features.key == 'all' }} + - name: Install rustfmt + run: rustup component add rustfmt + + - uses: actions/checkout@v4 + - name: test run: | ${{ env.RUST_CARGO_COMMAND }} ${{ matrix.platform.command }} ${{ matrix.platform.args }} --target ${{ matrix.platform.target }} diff --git a/.github/workflows/miri.yml b/.github/workflows/miri.yml index c6974f03f..3864edb32 100644 --- a/.github/workflows/miri.yml +++ b/.github/workflows/miri.yml @@ -87,8 +87,7 @@ jobs: # working-directory: tokio env: - # todo: disable memory leaks ignore - MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-strict-provenance -Zmiri-retag-fields -Zmiri-ignore-leaks + MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-strict-provenance -Zmiri-retag-fields PROPTEST_CASES: 10 # Cache the global cargo directory, but NOT the local `target` directory which diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 25e26f9a7..49147225c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -21,7 +21,7 @@ jobs: # Do our best to cache the toolchain and node install steps - uses: actions/checkout@v4 - uses: ilammy/setup-nasm@v1 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 16 - name: Install Rust diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b056a9590..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,171 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## Unreleased - -### Commit Statistics - - - - - 1 commit contributed to the release over the course of 7 calendar days. - - 0 commits where understood as [conventional](https://www.conventionalcommits.org). - - 0 issues like '(#ID)' where seen in commit messages - -### Commit Details - - - -
view details - - * **Uncategorized** - - Fix various typos and grammar nits ([`9e4ec43`](https://github.comgit//DioxusLabs/dioxus/commit/9e4ec43b1e78d355c56a38e4c092170b2b01b20d)) -
- -## v0.1.7 (2022-01-08) - -### Bug Fixes - - - tests - -### Commit Statistics - - - - - 1 commit contributed to the release over the course of 2 calendar days. - - 1 commit where understood as [conventional](https://www.conventionalcommits.org). - - 0 issues like '(#ID)' where seen in commit messages - -### Commit Details - - - -
view details - - * **Uncategorized** - - tests ([`bd341f5`](https://github.comgit//DioxusLabs/dioxus/commit/bd341f5571580cdf5e495379b49ca988fd9211c3)) -
- -## v0.1.1 (2021-12-15) - -### Documentation - - - update local examples and docs to support new syntaxes - - improve docs - - big updates to the reference - - lib.rs docs - - move around examples - -### New Features - - - it compiles once more - - massage lifetimes - - amazingly awesome error handling - - svgs working in webview - - mvoe away from compound context - - more suspended nodes! - - add aria - - enable arbitrary body in rsx! macro - -## v0.1.0 (2021-12-15) - -### Documentation - - - update local examples and docs to support new syntaxes - - improve docs - - big updates to the reference - - lib.rs docs - - move around examples - -### New Features - - - it compiles once more - - massage lifetimes - - amazingly awesome error handling - - svgs working in webview - - mvoe away from compound context - - more suspended nodes! - - add aria - - enable arbitrary body in rsx! macro - -## v0.0.1 (2022-01-03) - -### Documentation - - - improve docs - - update local examples and docs to support new syntaxes - - big updates to the reference - - lib.rs docs - - move around examples - -### New Features - - - it compiles once more - - massage lifetimes - - amazingly awesome error handling - - svgs working in webview - - mvoe away from compound context - - more suspended nodes! - - add aria - - enable arbitrary body in rsx! macro - -### Commit Statistics - - - - - 40 commits contributed to the release over the course of 193 calendar days. - - 38 commits where understood as [conventional](https://www.conventionalcommits.org). - - 0 issues like '(#ID)' where seen in commit messages - -### Commit Details - - - -
view details - - * **Uncategorized** - - remove runner on hook and then update docs ([`d156045`](https://github.comgit//DioxusLabs/dioxus/commit/d1560450bac55f9566e00e00ea405bd1c70b57e5)) - - polish some more things ([`1496102`](https://github.comgit//DioxusLabs/dioxus/commit/14961023f927b3a8bde83cfc7883aa8bfcca9e85)) - - upgrade hooks ([`b3ac2ee`](https://github.comgit//DioxusLabs/dioxus/commit/b3ac2ee3f76549cd1c7b6f9eee7e3382b07d873c)) - - docs ([`8814977`](https://github.comgit//DioxusLabs/dioxus/commit/8814977eeebe06748a3b9677a8070e42a037ebd7)) - - prepare to change our fragment pattern. Add some more docs ([`2c3a046`](https://github.comgit//DioxusLabs/dioxus/commit/2c3a0464264fa11e8100df025d863931f9606cdb)) - - it compiles once more ([`8acdd2e`](https://github.comgit//DioxusLabs/dioxus/commit/8acdd2ea830b995b608d8bac2ef527db8d40e662)) - - some docs and suspense ([`93d4b8c`](https://github.comgit//DioxusLabs/dioxus/commit/93d4b8ca7c1b133e5dba2a8dc9a310dbe1357001)) - - docs and router ([`a5f05d7`](https://github.comgit//DioxusLabs/dioxus/commit/a5f05d73acc0e47b05cff64a373482519414bc7c)) - - Merge branch 'master' into jk/remove_node_safety ([`db00047`](https://github.comgit//DioxusLabs/dioxus/commit/db0004758c77331cc3b93ea8cf227c060028e12e)) - - improve docs ([`7800744`](https://github.comgit//DioxusLabs/dioxus/commit/78007445f944f259170307d840e0f16242b7b4b6)) - - Various typos/grammar/rewording ([`5747e00`](https://github.comgit//DioxusLabs/dioxus/commit/5747e00b27b1b69c4f9c2820e7e78030feaff71e)) - - bubbling in progress ([`a21020e`](https://github.comgit//DioxusLabs/dioxus/commit/a21020ea575e467ba0d608737269fe1b0792dba7)) - - update local examples and docs to support new syntaxes ([`4de16c4`](https://github.comgit//DioxusLabs/dioxus/commit/4de16c4779648e591b3869b5df31271ae603c812)) - - massage lifetimes ([`9726a06`](https://github.comgit//DioxusLabs/dioxus/commit/9726a065b0d4fb1ede5b53a2ddd58c855e51539f)) - - major cleanups to scheduler ([`2933e4b`](https://github.comgit//DioxusLabs/dioxus/commit/2933e4bc11b3074c2bde8d76ec55364fca841988)) - - threadsafe ([`82953f2`](https://github.comgit//DioxusLabs/dioxus/commit/82953f2ac37913f83a822333acd0c47e20777d31)) - - move macro crate out of core ([`7bdad1e`](https://github.comgit//DioxusLabs/dioxus/commit/7bdad1e2e6f67e74c9f67dde2150140cf8a090e8)) - - amazingly awesome error handling ([`4a72b31`](https://github.comgit//DioxusLabs/dioxus/commit/4a72b3140bd244da602deada1eeecded65ff5848)) - - some ideas ([`05c909f`](https://github.comgit//DioxusLabs/dioxus/commit/05c909f320765aec1bf4c1c55ca59ffd5525a2c7)) - - big updates to the reference ([`583fdfa`](https://github.comgit//DioxusLabs/dioxus/commit/583fdfa5618e11d660985b97e570d4503be2ff49)) - - docs, html! macro, more ([`caf772c`](https://github.comgit//DioxusLabs/dioxus/commit/caf772cf249d2f56c8d0b0fa2737ad48e32c6e82)) - - cleanup workspace ([`8f0bb5d`](https://github.comgit//DioxusLabs/dioxus/commit/8f0bb5dc5bfa3e775af567c4b569622cdd932af1)) - - svgs working in webview ([`3bedcb9`](https://github.comgit//DioxusLabs/dioxus/commit/3bedcb93cacec5bdf134adc38ff02eadbf96c1c6)) - - mvoe away from compound context ([`a2c7d17`](https://github.comgit//DioxusLabs/dioxus/commit/a2c7d17b0595769f60bc1c2bbf7cbe32cec37486)) - - more suspended nodes! ([`de9f61b`](https://github.comgit//DioxusLabs/dioxus/commit/de9f61bcf48c0d6e35e46c337b72a713c9f9f7d2)) - - add aria ([`4091846`](https://github.comgit//DioxusLabs/dioxus/commit/4091846934b4b3b2bc03d3ca8aaf7712aebd4e36)) - - more examples ([`56e7eb8`](https://github.comgit//DioxusLabs/dioxus/commit/56e7eb83a97ebd6d5bcd23464cfb9d718e5ac26d)) - - more refactor for async ([`975fa56`](https://github.comgit//DioxusLabs/dioxus/commit/975fa566f9809f8fa2bb0bdb07fbfc7f855dcaeb)) - - enable arbitrary body in rsx! macro ([`7aec40d`](https://github.comgit//DioxusLabs/dioxus/commit/7aec40d57e78ec13ff3a90ca8149521cbf1d9ff2)) - - move CLI into its own "studio" app ([`fd79335`](https://github.comgit//DioxusLabs/dioxus/commit/fd7933561fe81922e4d5d77f6ac3b6f19efb5a90)) - - move some examples around ([`98a0933`](https://github.comgit//DioxusLabs/dioxus/commit/98a09339fd3190799ea4dd316908f0a53fdf2413)) - - fix issues with lifetimes ([`a38a81e`](https://github.comgit//DioxusLabs/dioxus/commit/a38a81e1290375cae685f7c49d3745e4298fab26)) - - more examples ([`11f89e5`](https://github.comgit//DioxusLabs/dioxus/commit/11f89e5d338d14a7aeece0a6275c24ae65913ce7)) - - lib.rs docs ([`bf21c82`](https://github.comgit//DioxusLabs/dioxus/commit/bf21c82de04e25daee60a06232b9a16b640508f2)) - - rename ctx to cx ([`81382e7`](https://github.comgit//DioxusLabs/dioxus/commit/81382e7044fb3dba61d4abb1e6086b7b29143116)) - - move around examples ([`70cd46d`](https://github.comgit//DioxusLabs/dioxus/commit/70cd46dbb2a689ae2d512e142b8aee9c80798430)) - - start moving events to rc ([`b9ff95f`](https://github.comgit//DioxusLabs/dioxus/commit/b9ff95fa12c46365fe73b64a4926a506d5da2342)) - - rename recoil to atoms ([`36ea39a`](https://github.comgit//DioxusLabs/dioxus/commit/36ea39ae30aa3f1fb2d718c0fdf08850c6bfd3ac)) - - more examples and docs ([`7fbaf69`](https://github.comgit//DioxusLabs/dioxus/commit/7fbaf69cabbdde712bb3fd9e4b2a5dc18b9390e9)) - - docs ([`f5683a2`](https://github.comgit//DioxusLabs/dioxus/commit/f5683a23464992ecace463a61414795b5a2c58c8)) -
- diff --git a/Cargo.toml b/Cargo.toml index e6b07eb8c..1772c1f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ members = [ "examples/tailwind", "examples/PWA-example", "examples/query_segments_demo", + "examples/openid_connect_demo", # Playwright tests "playwright-tests/liveview", "playwright-tests/web", @@ -49,7 +50,7 @@ members = [ exclude = ["examples/mobile_demo"] [workspace.package] -version = "0.4.2" +version = "0.4.3" # dependencies that are shared across packages [workspace.dependencies] @@ -76,7 +77,7 @@ dioxus-native-core = { path = "packages/native-core", version = "0.4.0" } dioxus-native-core-macro = { path = "packages/native-core-macro", version = "0.4.0" } rsx-rosetta = { path = "packages/rsx-rosetta", version = "0.4.0" } dioxus-signals = { path = "packages/signals" } -generational-box = { path = "packages/generational-box" } +generational-box = { path = "packages/generational-box", version = "0.4.3" } dioxus-hot-reload = { path = "packages/hot-reload", version = "0.4.0" } dioxus-fullstack = { path = "packages/fullstack", version = "0.4.1" } dioxus_server_macro = { path = "packages/server-macro", version = "0.4.1" } @@ -87,7 +88,7 @@ slab = "0.4.2" futures-channel = "0.3.21" futures-util = { version = "0.3", default-features = false } rustc-hash = "1.1.0" -wasm-bindgen = "0.2.87" +wasm-bindgen = "0.2.88" html_parser = "0.7.0" thiserror = "1.0.40" prettyplease = { package = "prettier-please", version = "0.2", features = [ @@ -98,7 +99,7 @@ prettyplease = { package = "prettier-please", version = "0.2", features = [ # It is not meant to be published, but is used so "cargo run --example XYZ" works properly [package] name = "dioxus-examples" -version = "0.0.0" +version = "0.4.3" authors = ["Jonathan Kelley"] edition = "2021" description = "Top level crate for the Dioxus repository" diff --git a/Makefile.toml b/Makefile.toml index 6f331b98f..1bfb02217 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -24,12 +24,64 @@ script = [ ] script_runner = "@duckscript" +[tasks.format] +command = "cargo" +args = ["fmt", "--all"] + +[tasks.check] +command = "cargo" +args = ["check", "--workspace", "--examples", "--tests"] + +[tasks.clippy] +command = "cargo" +args = [ + "clippy", + "--workspace", + "--examples", + "--tests", + "--", + "-D", + "warnings", +] + +[tasks.tidy] +category = "Formatting" +dependencies = ["format", "check", "clippy"] +description = "Format and Check workspace" + +[tasks.install-miri] +toolchain = "nightly" +install_crate = { rustup_component_name = "miri", binary = "cargo +nightly miri", test_arg = "--help" } +private = true + +[tasks.miri-native] +command = "cargo" +toolchain = "nightly" +dependencies = ["install-miri"] +args = [ + "miri", + "test", + "--package", + "dioxus-native-core", + "--test", + "miri_native", +] + +[tasks.miri-stress] +command = "cargo" +toolchain = "nightly" +dependencies = ["install-miri"] +args = ["miri", "test", "--package", "dioxus-core", "--test", "miri_stress"] + +[tasks.miri] +dependencies = ["miri-native", "miri-stress"] + [tasks.tests] category = "Testing" dependencies = ["tests-setup"] description = "Run all tests" -env = {CARGO_MAKE_WORKSPACE_SKIP_MEMBERS = ["**/examples/*"]} -run_task = {name = ["test-flow", "test-with-browser"], fork = true} +env = { CARGO_MAKE_WORKSPACE_SKIP_MEMBERS = ["**/examples/*"] } +run_task = { name = ["test-flow", "test-with-browser"], fork = true } [tasks.build] command = "cargo" @@ -42,10 +94,24 @@ private = true [tasks.test] dependencies = ["build"] command = "cargo" -args = ["test", "--lib", "--bins", "--tests", "--examples", "--workspace", "--exclude", "dioxus-router", "--exclude", "dioxus-desktop"] +args = [ + "test", + "--lib", + "--bins", + "--tests", + "--examples", + "--workspace", + "--exclude", + "dioxus-router", + "--exclude", + "dioxus-desktop", +] private = true [tasks.test-with-browser] -env = { CARGO_MAKE_WORKSPACE_INCLUDE_MEMBERS = ["**/packages/router", "**/packages/desktop"] } +env = { CARGO_MAKE_WORKSPACE_INCLUDE_MEMBERS = [ + "**/packages/router", + "**/packages/desktop", +] } private = true workspace = true diff --git a/README.md b/README.md index 5b6a99ad3..d1ec98019 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ So... Dioxus is great, but why won't it work for me? ## Contributing +- Check out the website [section on contributing](https://dioxuslabs.com/learn/0.4/contributing). - Report issues on our [issue tracker](https://github.com/dioxuslabs/dioxus/issues). - Join the discord and ask questions! diff --git a/examples/calculator.rs b/examples/calculator.rs index bc7f13459..648ccfbc3 100644 --- a/examples/calculator.rs +++ b/examples/calculator.rs @@ -62,6 +62,7 @@ fn app(cx: Scope) -> Element { div { id: "wrapper", div { class: "app", div { class: "calculator", + tabindex: "0", onkeydown: handle_key_down_event, div { class: "calculator-display", val.to_string() } div { class: "calculator-keypad", diff --git a/examples/openid_connect_demo/.gitignore b/examples/openid_connect_demo/.gitignore new file mode 100644 index 000000000..919c8ee55 --- /dev/null +++ b/examples/openid_connect_demo/.gitignore @@ -0,0 +1,3 @@ +/target +/dist +.env diff --git a/examples/openid_connect_demo/Cargo.toml b/examples/openid_connect_demo/Cargo.toml new file mode 100644 index 000000000..2a1abe173 --- /dev/null +++ b/examples/openid_connect_demo/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openid_auth_demo" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +console_error_panic_hook = "0.1" +dioxus-logger = "0.4.1" +dioxus = { path = "../../packages/dioxus", version = "*" } +dioxus-router = { path = "../../packages/router", version = "*" } +dioxus-web = { path = "../../packages/web", version = "*" } +fermi = { path = "../../packages/fermi", version = "*" } +form_urlencoded = "1.2.0" +gloo-storage = "0.3.0" +log = "0.4" +openidconnect = "3.4.0" +reqwest = "0.11.20" +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.105" +thiserror = "1.0.48" +uuid = "1.4" +web-sys = { version = "0.3", features = ["Request", "Document"] } diff --git a/examples/openid_connect_demo/Dioxus.toml b/examples/openid_connect_demo/Dioxus.toml new file mode 100644 index 000000000..483dfe957 --- /dev/null +++ b/examples/openid_connect_demo/Dioxus.toml @@ -0,0 +1,47 @@ +[application] + +# dioxus project name +name = "OpenID Connect authentication demo" + +# default platfrom +# you can also use `dioxus serve/build --platform XXX` to use other platform +# value: web | desktop +default_platform = "web" + +# Web `build` & `serve` dist path +out_dir = "dist" + +# resource (static) file folder +asset_dir = "public" + +[web.app] + +# HTML title tag content +title = "OpenID Connect authentication demo" + +[web.watcher] + +index_on_404 = true + +watch_path = ["src"] + +# include `assets` in web platform +[web.resource] + +# CSS style file +style = [] + +# Javascript code file +script = [] + +[web.resource.dev] + +# Javascript code file +# serve: [dev-server] only +script = [] + +[application.plugins] + +available = true + +required = [] diff --git a/examples/openid_connect_demo/README.md b/examples/openid_connect_demo/README.md new file mode 100644 index 000000000..e16467ddf --- /dev/null +++ b/examples/openid_connect_demo/README.md @@ -0,0 +1,13 @@ +# OpenID Connect example to show how to authenticate an user + +The environment variables in `.cargo/config.toml` must be set in order for this example to work(if this example is just being compiled from the root workspace, the `.cargo/config.toml` from the root workspace must be set as stated in the [Cargo book](https://doc.rust-lang.org/cargo/reference/config.html)). + +Once they are set, you can run `dx serve` + +### Environment variables summary + +```DIOXUS_FRONT_ISSUER_URL``` The openid-connect's issuer url + +```DIOXUS_FRONT_CLIENT_ID``` The openid-connect's client id + +```DIOXUS_FRONT_URL``` The url the frontend is supposed to be running on, it could be for example `http://localhost:8080` \ No newline at end of file diff --git a/examples/openid_connect_demo/src/constants.rs b/examples/openid_connect_demo/src/constants.rs new file mode 100644 index 000000000..0a0b4950a --- /dev/null +++ b/examples/openid_connect_demo/src/constants.rs @@ -0,0 +1,2 @@ +pub const DIOXUS_FRONT_AUTH_TOKEN: &str = "auth_token"; +pub const DIOXUS_FRONT_AUTH_REQUEST: &str = "auth_request"; diff --git a/examples/openid_connect_demo/src/errors.rs b/examples/openid_connect_demo/src/errors.rs new file mode 100644 index 000000000..2b7e3f145 --- /dev/null +++ b/examples/openid_connect_demo/src/errors.rs @@ -0,0 +1,20 @@ +use openidconnect::{core::CoreErrorResponseType, url, RequestTokenError, StandardErrorResponse}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Discovery error: {0}")] + OpenIdConnect( + #[from] openidconnect::DiscoveryError>, + ), + #[error("Parsing error: {0}")] + Parse(#[from] url::ParseError), + #[error("Request token error: {0}")] + RequestToken( + #[from] + RequestTokenError< + openidconnect::reqwest::Error, + StandardErrorResponse, + >, + ), +} diff --git a/examples/openid_connect_demo/src/main.rs b/examples/openid_connect_demo/src/main.rs new file mode 100644 index 000000000..7ed8db8a9 --- /dev/null +++ b/examples/openid_connect_demo/src/main.rs @@ -0,0 +1,60 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use fermi::*; +use gloo_storage::{LocalStorage, Storage}; +use log::LevelFilter; +pub(crate) mod constants; +pub(crate) mod errors; +pub(crate) mod model; +pub(crate) mod oidc; +pub(crate) mod props; +pub(crate) mod router; +pub(crate) mod storage; +pub(crate) mod views; +use oidc::{AuthRequestState, AuthTokenState}; +use router::Route; + +use dioxus_router::prelude::*; + +use crate::{ + constants::{DIOXUS_FRONT_AUTH_REQUEST, DIOXUS_FRONT_AUTH_TOKEN}, + oidc::ClientState, +}; +pub static FERMI_CLIENT: fermi::AtomRef = AtomRef(|_| ClientState::default()); + +// An option is required to prevent the component from being constantly refreshed +pub static FERMI_AUTH_TOKEN: fermi::AtomRef> = AtomRef(|_| None); +pub static FERMI_AUTH_REQUEST: fermi::AtomRef> = AtomRef(|_| None); + +pub static DIOXUS_FRONT_ISSUER_URL: &str = env!("DIOXUS_FRONT_ISSUER_URL"); +pub static DIOXUS_FRONT_CLIENT_ID: &str = env!("DIOXUS_FRONT_CLIENT_ID"); +pub static DIOXUS_FRONT_URL: &str = env!("DIOXUS_FRONT_URL"); + +fn App(cx: Scope) -> Element { + use_init_atom_root(cx); + + // Retrieve the value stored in the browser's storage + let stored_auth_token = LocalStorage::get(DIOXUS_FRONT_AUTH_TOKEN) + .ok() + .unwrap_or(AuthTokenState::default()); + let fermi_auth_token = use_atom_ref(cx, &FERMI_AUTH_TOKEN); + if fermi_auth_token.read().is_none() { + *fermi_auth_token.write() = Some(stored_auth_token); + } + + let stored_auth_request = LocalStorage::get(DIOXUS_FRONT_AUTH_REQUEST) + .ok() + .unwrap_or(AuthRequestState::default()); + let fermi_auth_request = use_atom_ref(cx, &FERMI_AUTH_REQUEST); + if fermi_auth_request.read().is_none() { + *fermi_auth_request.write() = Some(stored_auth_request); + } + render! { Router:: {} } +} + +fn main() { + dioxus_logger::init(LevelFilter::Info).expect("failed to init logger"); + console_error_panic_hook::set_once(); + log::info!("starting app"); + dioxus_web::launch(App); +} diff --git a/examples/openid_connect_demo/src/model/mod.rs b/examples/openid_connect_demo/src/model/mod.rs new file mode 100644 index 000000000..68a79f5c0 --- /dev/null +++ b/examples/openid_connect_demo/src/model/mod.rs @@ -0,0 +1 @@ +pub(crate) mod user; diff --git a/examples/openid_connect_demo/src/model/user.rs b/examples/openid_connect_demo/src/model/user.rs new file mode 100644 index 000000000..b1005fdd2 --- /dev/null +++ b/examples/openid_connect_demo/src/model/user.rs @@ -0,0 +1,7 @@ +use uuid::Uuid; + +#[derive(PartialEq)] +pub struct User { + pub id: Uuid, + pub name: String, +} diff --git a/examples/openid_connect_demo/src/oidc.rs b/examples/openid_connect_demo/src/oidc.rs new file mode 100644 index 000000000..7fa96c14b --- /dev/null +++ b/examples/openid_connect_demo/src/oidc.rs @@ -0,0 +1,125 @@ +use openidconnect::{ + core::{CoreClient, CoreErrorResponseType, CoreIdToken, CoreResponseType, CoreTokenResponse}, + reqwest::async_http_client, + url::Url, + AuthenticationFlow, AuthorizationCode, ClaimsVerificationError, ClientId, CsrfToken, IssuerUrl, + LogoutRequest, Nonce, ProviderMetadataWithLogout, RedirectUrl, RefreshToken, RequestTokenError, + StandardErrorResponse, +}; +use serde::{Deserialize, Serialize}; + +use crate::{props::client::ClientProps, DIOXUS_FRONT_CLIENT_ID}; + +#[derive(Clone, Debug, Default)] +pub struct ClientState { + pub oidc_client: Option, +} + +/// State that holds the nonce and authorization url and the nonce generated to log in an user +#[derive(Clone, Deserialize, Serialize, Default)] +pub struct AuthRequestState { + pub auth_request: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct AuthRequest { + pub nonce: Nonce, + pub authorize_url: String, +} + +/// State the tokens returned once the user is authenticated +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct AuthTokenState { + /// Token used to identify the user + pub id_token: Option, + /// Token used to refresh the tokens if they expire + pub refresh_token: Option, +} + +pub fn email( + client: CoreClient, + id_token: CoreIdToken, + nonce: Nonce, +) -> Result { + match id_token.claims(&client.id_token_verifier(), &nonce) { + Ok(claims) => Ok(claims.clone().email().unwrap().to_string()), + Err(error) => Err(error), + } +} + +pub fn authorize_url(client: CoreClient) -> AuthRequest { + let (authorize_url, _csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(openidconnect::Scope::new("email".to_string())) + .add_scope(openidconnect::Scope::new("profile".to_string())) + .url(); + AuthRequest { + authorize_url: authorize_url.to_string(), + nonce, + } +} + +pub async fn init_provider_metadata() -> Result { + let issuer_url = IssuerUrl::new(crate::DIOXUS_FRONT_ISSUER_URL.to_string())?; + Ok(ProviderMetadataWithLogout::discover_async(issuer_url, async_http_client).await?) +} + +pub async fn init_oidc_client() -> Result<(ClientId, CoreClient), crate::errors::Error> { + let client_id = ClientId::new(crate::DIOXUS_FRONT_CLIENT_ID.to_string()); + let provider_metadata = init_provider_metadata().await?; + let client_secret = None; + let redirect_url = RedirectUrl::new(format!("{}/login", crate::DIOXUS_FRONT_URL))?; + + Ok(( + client_id.clone(), + CoreClient::from_provider_metadata(provider_metadata, client_id, client_secret) + .set_redirect_uri(redirect_url), + )) +} + +///TODO: Add pkce_pacifier +pub async fn token_response( + oidc_client: CoreClient, + code: String, +) -> Result { + // let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + Ok(oidc_client + .exchange_code(AuthorizationCode::new(code.clone())) + // .set_pkce_verifier(pkce_verifier) + .request_async(async_http_client) + .await?) +} + +pub async fn exchange_refresh_token( + oidc_client: CoreClient, + refresh_token: RefreshToken, +) -> Result< + CoreTokenResponse, + RequestTokenError< + openidconnect::reqwest::Error, + StandardErrorResponse, + >, +> { + oidc_client + .exchange_refresh_token(&refresh_token) + .request_async(async_http_client) + .await +} + +pub async fn log_out_url(id_token_hint: CoreIdToken) -> Result { + let provider_metadata = init_provider_metadata().await?; + let end_session_url = provider_metadata + .additional_metadata() + .clone() + .end_session_endpoint + .unwrap(); + let logout_request: LogoutRequest = LogoutRequest::from(end_session_url); + Ok(logout_request + .set_client_id(ClientId::new(DIOXUS_FRONT_CLIENT_ID.to_string())) + .set_id_token_hint(&id_token_hint) + .http_get_url()) +} diff --git a/examples/openid_connect_demo/src/props/client.rs b/examples/openid_connect_demo/src/props/client.rs new file mode 100644 index 000000000..2e02111e1 --- /dev/null +++ b/examples/openid_connect_demo/src/props/client.rs @@ -0,0 +1,20 @@ +use dioxus::prelude::*; +use openidconnect::{core::CoreClient, ClientId}; + +#[derive(Props, Clone, Debug)] +pub struct ClientProps { + pub client: CoreClient, + pub client_id: ClientId, +} + +impl PartialEq for ClientProps { + fn eq(&self, other: &Self) -> bool { + self.client_id == other.client_id + } +} + +impl ClientProps { + pub fn new(client_id: ClientId, client: CoreClient) -> Self { + ClientProps { client_id, client } + } +} diff --git a/examples/openid_connect_demo/src/props/mod.rs b/examples/openid_connect_demo/src/props/mod.rs new file mode 100644 index 000000000..1d3313153 --- /dev/null +++ b/examples/openid_connect_demo/src/props/mod.rs @@ -0,0 +1 @@ +pub(crate) mod client; diff --git a/examples/openid_connect_demo/src/router.rs b/examples/openid_connect_demo/src/router.rs new file mode 100644 index 000000000..0bb80afbb --- /dev/null +++ b/examples/openid_connect_demo/src/router.rs @@ -0,0 +1,17 @@ +use crate::views::{header::AuthHeader, home::Home, login::Login, not_found::NotFound}; +use dioxus::prelude::*; +use dioxus_router::prelude::*; + +#[derive(Routable, Clone)] +pub enum Route { + #[layout(AuthHeader)] + #[route("/")] + Home {}, + + // https://dioxuslabs.com/learn/0.4/router/reference/routes#query-segments + #[route("/login?:query_string")] + Login { query_string: String }, + #[end_layout] + #[route("/:..route")] + NotFound { route: Vec }, +} diff --git a/examples/openid_connect_demo/src/storage.rs b/examples/openid_connect_demo/src/storage.rs new file mode 100644 index 000000000..402740cdf --- /dev/null +++ b/examples/openid_connect_demo/src/storage.rs @@ -0,0 +1,38 @@ +use fermi::UseAtomRef; +use gloo_storage::{LocalStorage, Storage}; +use serde::{Deserialize, Serialize}; + +use crate::{ + constants::{DIOXUS_FRONT_AUTH_REQUEST, DIOXUS_FRONT_AUTH_TOKEN}, + oidc::{AuthRequestState, AuthTokenState}, +}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct StorageEntry { + pub key: String, + pub value: T, +} + +pub trait PersistentWrite { + fn persistent_set(atom_ref: &UseAtomRef>, entry: Option); +} + +impl PersistentWrite for AuthTokenState { + fn persistent_set( + atom_ref: &UseAtomRef>, + entry: Option, + ) { + *atom_ref.write() = entry.clone(); + LocalStorage::set(DIOXUS_FRONT_AUTH_TOKEN, entry).unwrap(); + } +} + +impl PersistentWrite for AuthRequestState { + fn persistent_set( + atom_ref: &UseAtomRef>, + entry: Option, + ) { + *atom_ref.write() = entry.clone(); + LocalStorage::set(DIOXUS_FRONT_AUTH_REQUEST, entry).unwrap(); + } +} diff --git a/examples/openid_connect_demo/src/views/header.rs b/examples/openid_connect_demo/src/views/header.rs new file mode 100644 index 000000000..1f2c6e057 --- /dev/null +++ b/examples/openid_connect_demo/src/views/header.rs @@ -0,0 +1,250 @@ +use crate::{ + oidc::{ + authorize_url, email, exchange_refresh_token, init_oidc_client, log_out_url, + AuthRequestState, AuthTokenState, ClientState, + }, + props::client::ClientProps, + router::Route, + storage::PersistentWrite, + FERMI_AUTH_REQUEST, FERMI_AUTH_TOKEN, FERMI_CLIENT, +}; +use dioxus::prelude::*; +use dioxus_router::prelude::{Link, Outlet}; +use fermi::*; +use openidconnect::{url::Url, OAuth2TokenResponse, TokenResponse}; + +#[component] +pub fn LogOut(cx: Scope) -> Element { + let fermi_auth_token = use_atom_ref(cx, &FERMI_AUTH_TOKEN); + let fermi_auth_token_read = fermi_auth_token.read().clone(); + let log_out_url_state = use_state(cx, || None::>>); + cx.render(match fermi_auth_token_read { + Some(fermi_auth_token_read) => match fermi_auth_token_read.id_token.clone() { + Some(id_token) => match log_out_url_state.get() { + Some(log_out_url_result) => match log_out_url_result { + Some(uri) => match uri { + Ok(uri) => { + rsx! { + Link { + onclick: move |_| { + { + AuthTokenState::persistent_set( + fermi_auth_token, + Some(AuthTokenState::default()), + ); + } + }, + to: uri.to_string(), + "Log out" + } + } + } + Err(error) => { + rsx! { + div { format!{"Failed to load disconnection url: {:?}", error} } + } + } + }, + None => { + rsx! { div { "Loading... Please wait" } } + } + }, + None => { + let logout_url_task = move || { + cx.spawn({ + let log_out_url_state = log_out_url_state.to_owned(); + async move { + let logout_url = log_out_url(id_token).await; + let logout_url_option = Some(logout_url); + log_out_url_state.set(Some(logout_url_option)); + } + }) + }; + logout_url_task(); + rsx! { div{"Loading log out url... Please wait"}} + } + }, + None => { + rsx! {{}} + } + }, + None => { + rsx! {{}} + } + }) +} + +#[component] +pub fn RefreshToken(cx: Scope) -> Element { + let fermi_auth_token = use_atom_ref(cx, &FERMI_AUTH_TOKEN); + let fermi_auth_request = use_atom_ref(cx, &FERMI_AUTH_REQUEST); + let fermi_auth_token_read = fermi_auth_token.read().clone(); + cx.render(match fermi_auth_token_read { + Some(fermi_auth_client_read) => match fermi_auth_client_read.refresh_token { + Some(refresh_token) => { + let fermi_auth_token = fermi_auth_token.to_owned(); + let fermi_auth_request = fermi_auth_request.to_owned(); + let client = cx.props.client.clone(); + let exchange_refresh_token_spawn = move || { + cx.spawn({ + async move { + let exchange_refresh_token = + exchange_refresh_token(client, refresh_token).await; + match exchange_refresh_token { + Ok(response_token) => { + AuthTokenState::persistent_set( + &fermi_auth_token, + Some(AuthTokenState { + id_token: response_token.id_token().cloned(), + refresh_token: response_token.refresh_token().cloned(), + }), + ); + } + Err(_error) => { + AuthTokenState::persistent_set( + &fermi_auth_token, + Some(AuthTokenState::default()), + ); + AuthRequestState::persistent_set( + &fermi_auth_request, + Some(AuthRequestState::default()), + ); + } + } + } + }) + }; + exchange_refresh_token_spawn(); + rsx! { div { "Refreshing session, please wait" } } + } + None => { + rsx! { div { "Id token expired and no refresh token found" } } + } + }, + None => { + rsx! {{}} + } + }) +} + +#[component] +pub fn LoadClient(cx: Scope) -> Element { + let init_client_future = use_future(cx, (), |_| async move { init_oidc_client().await }); + let fermi_client: &UseAtomRef = use_atom_ref(cx, &FERMI_CLIENT); + cx.render(match init_client_future.value() { + Some(client_props) => match client_props { + Ok((client_id, client)) => { + *fermi_client.write() = ClientState { + oidc_client: Some(ClientProps::new(client_id.clone(), client.clone())), + }; + rsx! { + div { "Client successfully loaded" } + Outlet:: {} + } + } + Err(error) => { + rsx! { + div { format!{"Failed to load client: {:?}", error} } + log::info!{"Failed to load client: {:?}", error}, + Outlet:: {} + } + } + }, + None => { + rsx! { + div { + div { "Loading client, please wait" } + Outlet:: {} + } + } + } + }) +} + +#[component] +pub fn AuthHeader(cx: Scope) -> Element { + let auth_token = use_atom_ref(cx, &FERMI_AUTH_TOKEN); + let fermi_auth_request = use_atom_ref(cx, &FERMI_AUTH_REQUEST); + let fermi_client: &UseAtomRef = use_atom_ref(cx, &FERMI_CLIENT); + let client = fermi_client.read().oidc_client.clone(); + let auth_request_read = fermi_auth_request.read().clone(); + let auth_token_read = auth_token.read().clone(); + cx.render(match (client, auth_request_read, auth_token_read) { + // We have everything we need to attempt to authenticate the user + (Some(client_props), Some(auth_request), Some(auth_token)) => { + match auth_request.auth_request { + Some(auth_request) => { + match auth_token.id_token { + Some(id_token) => { + match email( + client_props.client.clone(), + id_token.clone(), + auth_request.nonce.clone(), + ) { + Ok(email) => { + rsx! { + div { + div { email } + LogOut { client_id: client_props.client_id, client: client_props.client } + Outlet:: {} + } + } + } + // Id token failed to be decoded + Err(error) => match error { + // Id token failed to be decoded because it expired, we refresh it + openidconnect::ClaimsVerificationError::Expired(_message) => { + log::info!("Token expired"); + rsx! { + div { + RefreshToken {client_id: client_props.client_id, client: client_props.client} + Outlet:: {} + } + } + } + // Other issue with token decoding + _ => { + log::info!("Other issue with token"); + rsx! { + div { + div { error.to_string() } + Outlet:: {} + } + } + } + }, + } + } + // User is not logged in + None => { + rsx! { + div { + Link { to: auth_request.authorize_url.clone(), "Log in" } + Outlet:: {} + } + } + } + } + } + None => { + let auth_request = authorize_url(client_props.client); + AuthRequestState::persistent_set( + fermi_auth_request, + Some(AuthRequestState { + auth_request: Some(auth_request), + }), + ); + rsx! { div { "Loading nonce" } } + } + } + } + // Client is not initialized yet, we need it for everything + (None, _, _) => { + rsx! { LoadClient {} } + } + // We need everything loaded before doing anything + (_client, _auth_request, _auth_token) => { + rsx! {{}} + } + }) +} diff --git a/examples/openid_connect_demo/src/views/home.rs b/examples/openid_connect_demo/src/views/home.rs new file mode 100644 index 000000000..f39695302 --- /dev/null +++ b/examples/openid_connect_demo/src/views/home.rs @@ -0,0 +1,5 @@ +use dioxus::prelude::*; + +pub fn Home(cx: Scope) -> Element { + render! { div { "Hello world" } } +} diff --git a/examples/openid_connect_demo/src/views/login.rs b/examples/openid_connect_demo/src/views/login.rs new file mode 100644 index 000000000..2583503de --- /dev/null +++ b/examples/openid_connect_demo/src/views/login.rs @@ -0,0 +1,86 @@ +use crate::{ + oidc::{token_response, AuthRequestState, AuthTokenState}, + router::Route, + storage::PersistentWrite, + DIOXUS_FRONT_URL, FERMI_AUTH_REQUEST, FERMI_AUTH_TOKEN, FERMI_CLIENT, +}; +use dioxus::prelude::*; +use dioxus_router::prelude::{Link, NavigationTarget}; +use fermi::*; +use openidconnect::{OAuth2TokenResponse, TokenResponse}; + +#[component] +pub fn Login(cx: Scope, query_string: String) -> Element { + let fermi_client = use_atom_ref(cx, &FERMI_CLIENT); + let fermi_auth_token = use_atom_ref(cx, &FERMI_AUTH_TOKEN); + let home_url: NavigationTarget = DIOXUS_FRONT_URL.parse().unwrap(); + let fermi_auth_request = use_atom_ref(cx, &FERMI_AUTH_REQUEST); + let client = fermi_client.read().oidc_client.clone(); + let auth_token_read = fermi_auth_token.read().clone(); + cx.render(match (client, auth_token_read) { + (Some(client_props), Some(auth_token_read)) => { + match (auth_token_read.id_token, auth_token_read.refresh_token) { + (Some(_id_token), Some(_refresh_token)) => { + rsx! { + div { "Sign in successful" } + Link { to: home_url, "Go back home" } + } + } + // If the refresh token is set but not the id_token, there was an error, we just go back home and reset their value + (None, Some(_)) | (Some(_), None) => { + rsx! { + div { "Error while attempting to log in" } + Link { + to: home_url, + onclick: move |_| { + AuthTokenState::persistent_set(fermi_auth_token, Some(AuthTokenState::default())); + AuthRequestState::persistent_set( + fermi_auth_request, + Some(AuthRequestState::default()), + ); + }, + "Go back home" + } + } + } + (None, None) => { + let mut query_pairs = form_urlencoded::parse(query_string.as_bytes()); + let code_pair = query_pairs.find(|(key, _value)| key == "code"); + match code_pair { + Some((_key, code)) => { + let auth_code = code.to_string(); + let token_response_spawn = move ||{ + cx.spawn({ + let fermi_auth_token = fermi_auth_token.to_owned(); + async move { + let token_response_result = token_response(client_props.client, auth_code).await; + match token_response_result{ + Ok(token_response) => { + let id_token = token_response.id_token().unwrap(); + AuthTokenState::persistent_set(&fermi_auth_token, Some(AuthTokenState { + id_token: Some(id_token.clone()), + refresh_token: token_response.refresh_token().cloned() + })); + } + Err(error) => { + log::warn!{"{error}"}; + } + } + } + }) + }; + token_response_spawn(); + rsx!{ div {} } + } + None => { + rsx! { div { "No code provided" } } + } + } + } + } + } + (_, _) => { + rsx! {{}} + } + }) +} diff --git a/examples/openid_connect_demo/src/views/mod.rs b/examples/openid_connect_demo/src/views/mod.rs new file mode 100644 index 000000000..1b8e08a58 --- /dev/null +++ b/examples/openid_connect_demo/src/views/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod header; +pub(crate) mod home; +pub(crate) mod login; +pub(crate) mod not_found; diff --git a/examples/openid_connect_demo/src/views/not_found.rs b/examples/openid_connect_demo/src/views/not_found.rs new file mode 100644 index 000000000..6321dc9fc --- /dev/null +++ b/examples/openid_connect_demo/src/views/not_found.rs @@ -0,0 +1,7 @@ +use dioxus::prelude::*; + +#[component] +pub fn NotFound(cx: Scope, route: Vec) -> Element { + let routes = route.join(""); + render! {rsx! {div{routes}}} +} diff --git a/examples/optional_props.rs b/examples/optional_props.rs index c5652a2e7..8108d437d 100644 --- a/examples/optional_props.rs +++ b/examples/optional_props.rs @@ -16,8 +16,20 @@ fn app(cx: Scope) -> Element { a: "asd".to_string(), c: "asd".to_string(), d: Some("asd".to_string()), + e: Some("asd".to_string()), + } + Button { + a: "asd".to_string(), + b: "asd".to_string(), + c: "asd".to_string(), + d: Some("asd".to_string()), e: "asd".to_string(), } + Button { + a: "asd".to_string(), + c: "asd".to_string(), + d: Some("asd".to_string()), + } }) } diff --git a/examples/query_segments_demo/Cargo.toml b/examples/query_segments_demo/Cargo.toml index 4a7d784c7..ee9f49c70 100644 --- a/examples/query_segments_demo/Cargo.toml +++ b/examples/query_segments_demo/Cargo.toml @@ -2,6 +2,7 @@ name = "query_segments_demo" version = "0.1.0" edition = "2021" +publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/signals.rs b/examples/signals.rs index bc627fa8f..0faac0af4 100644 --- a/examples/signals.rs +++ b/examples/signals.rs @@ -6,11 +6,17 @@ fn main() { } fn app(cx: Scope) -> Element { + let running = dioxus_signals::use_signal(cx, || true); let mut count = dioxus_signals::use_signal(cx, || 0); + let saved_values = dioxus_signals::use_signal(cx, || vec![0.to_string()]); + // Signals can be used in async functions without an explicit clone since they're 'static and Copy + // Signals are backed by a runtime that is designed to deeply integrate with Dioxus apps use_future!(cx, || async move { loop { - count += 1; + if running.value() { + count += 1; + } tokio::time::sleep(Duration::from_millis(400)).await; } }); @@ -19,9 +25,25 @@ fn app(cx: Scope) -> Element { h1 { "High-Five counter: {count}" } button { onclick: move |_| count += 1, "Up high!" } button { onclick: move |_| count -= 1, "Down low!" } + button { onclick: move |_| running.toggle(), "Toggle counter" } + button { onclick: move |_| saved_values.push(count.value().to_string()), "Save this value" } + button { onclick: move |_| saved_values.write().clear(), "Clear saved values" } + // We can do boolean operations on the current signal value if count.value() > 5 { rsx!{ h2 { "High five!" } } } + + // We can cleanly map signals with iterators + for value in saved_values.read().iter() { + h3 { "Saved value: {value}" } + } + + // We can also use the signal value as a slice + if let [ref first, .., ref last] = saved_values.read().as_slice() { + rsx! { li { "First and last: {first}, {last}" } } + } else { + rsx! { "No saved values" } + } }) } diff --git a/examples/tailwind/Cargo.toml b/examples/tailwind/Cargo.toml index 5c2fbe666..26ac202f1 100644 --- a/examples/tailwind/Cargo.toml +++ b/examples/tailwind/Cargo.toml @@ -18,4 +18,4 @@ dioxus = { path = "../../packages/dioxus" } dioxus-desktop = { path = "../../packages/desktop" } [target.'cfg(target_arch = "wasm32")'.dependencies] -dioxus-web = { path = "../../packages/web" } \ No newline at end of file +dioxus-web = { path = "../../packages/web" } diff --git a/examples/todomvc.rs b/examples/todomvc.rs index 4b25507a5..9ec2dc228 100644 --- a/examples/todomvc.rs +++ b/examples/todomvc.rs @@ -24,8 +24,6 @@ pub struct TodoItem { pub fn app(cx: Scope<()>) -> Element { let todos = use_state(cx, im_rc::HashMap::::default); let filter = use_state(cx, || FilterState::All); - let draft = use_state(cx, || "".to_string()); - let todo_id = use_state(cx, || 0); // Filter the todos based on the filter state let mut filtered_todos = todos @@ -47,42 +45,11 @@ pub fn app(cx: Scope<()>) -> Element { let show_clear_completed = todos.values().any(|todo| todo.checked); - let selected = |state| { - if *filter == state { - "selected" - } else { - "false" - } - }; - cx.render(rsx! { section { class: "todoapp", style { include_str!("./assets/todomvc.css") } - header { class: "header", - h1 {"todos"} - input { - class: "new-todo", - placeholder: "What needs to be done?", - value: "{draft}", - autofocus: "true", - oninput: move |evt| { - draft.set(evt.value.clone()); - }, - onkeydown: move |evt| { - if evt.key() == Key::Enter && !draft.is_empty() { - todos.make_mut().insert( - **todo_id, - TodoItem { - id: **todo_id, - checked: false, - contents: draft.to_string(), - }, - ); - *todo_id.make_mut() += 1; - draft.set("".to_string()); - } - } - } + TodoHeader { + todos: todos, } section { class: "main", @@ -111,44 +78,56 @@ pub fn app(cx: Scope<()>) -> Element { })) } (!todos.is_empty()).then(|| rsx!( - footer { class: "footer", - span { class: "todo-count", - strong {"{active_todo_count} "} - span {"{active_todo_text} left"} - } - ul { class: "filters", - for (state, state_text, url) in [ - (FilterState::All, "All", "#/"), - (FilterState::Active, "Active", "#/active"), - (FilterState::Completed, "Completed", "#/completed"), - ] { - li { - a { - href: url, - class: selected(state), - onclick: move |_| filter.set(state), - prevent_default: "onclick", - state_text - } - } - } - } - show_clear_completed.then(|| rsx!( - button { - class: "clear-completed", - onclick: move |_| todos.make_mut().retain(|_, todo| !todo.checked), - "Clear completed" - } - )) + ListFooter { + active_todo_count: active_todo_count, + active_todo_text: active_todo_text, + show_clear_completed: show_clear_completed, + todos: todos, + filter: filter, } )) } } - footer { class: "info", - p { "Double-click to edit a todo" } - p { "Created by ", a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" }} - p { "Part of ", a { href: "http://todomvc.com", "TodoMVC" }} + PageFooter {} + }) +} + +#[derive(Props)] +pub struct TodoHeaderProps<'a> { + todos: &'a UseState>, +} + +pub fn TodoHeader<'a>(cx: Scope<'a, TodoHeaderProps<'a>>) -> Element { + let draft = use_state(cx, || "".to_string()); + let todo_id = use_state(cx, || 0); + + cx.render(rsx! { + header { class: "header", + h1 {"todos"} + input { + class: "new-todo", + placeholder: "What needs to be done?", + value: "{draft}", + autofocus: "true", + oninput: move |evt| { + draft.set(evt.value.clone()); + }, + onkeydown: move |evt| { + if evt.key() == Key::Enter && !draft.is_empty() { + cx.props.todos.make_mut().insert( + **todo_id, + TodoItem { + id: **todo_id, + checked: false, + contents: draft.to_string(), + }, + ); + *todo_id.make_mut() += 1; + draft.set("".to_string()); + } + } } + } }) } @@ -209,3 +188,70 @@ pub fn TodoEntry<'a>(cx: Scope<'a, TodoEntryProps<'a>>) -> Element { } }) } + +#[derive(Props)] +pub struct ListFooterProps<'a> { + todos: &'a UseState>, + active_todo_count: usize, + active_todo_text: &'a str, + show_clear_completed: bool, + filter: &'a UseState, +} + +pub fn ListFooter<'a>(cx: Scope<'a, ListFooterProps<'a>>) -> Element { + let active_todo_count = cx.props.active_todo_count; + let active_todo_text = cx.props.active_todo_text; + + let selected = |state| { + if *cx.props.filter == state { + "selected" + } else { + "false" + } + }; + + cx.render(rsx! { + footer { class: "footer", + span { class: "todo-count", + strong {"{active_todo_count} "} + span {"{active_todo_text} left"} + } + ul { class: "filters", + for (state, state_text, url) in [ + (FilterState::All, "All", "#/"), + (FilterState::Active, "Active", "#/active"), + (FilterState::Completed, "Completed", "#/completed"), + ] { + li { + a { + href: url, + class: selected(state), + onclick: move |_| cx.props.filter.set(state), + prevent_default: "onclick", + state_text + } + } + } + } + if cx.props.show_clear_completed { + cx.render(rsx! { + button { + class: "clear-completed", + onclick: move |_| cx.props.todos.make_mut().retain(|_, todo| !todo.checked), + "Clear completed" + } + }) + } + } + }) +} + +pub fn PageFooter(cx: Scope) -> Element { + cx.render(rsx! { + footer { class: "info", + p { "Double-click to edit a todo" } + p { "Created by ", a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" }} + p { "Part of ", a { href: "http://todomvc.com", "TodoMVC" }} + } + }) +} diff --git a/packages/autofmt/src/buffer.rs b/packages/autofmt/src/buffer.rs index fea5a4a3f..f19a7d8dc 100644 --- a/packages/autofmt/src/buffer.rs +++ b/packages/autofmt/src/buffer.rs @@ -8,13 +8,14 @@ use std::fmt::{Result, Write}; use dioxus_rsx::IfmtInput; -use crate::write_ifmt; +use crate::{indent::IndentOptions, write_ifmt}; /// The output buffer that tracks indent and string #[derive(Debug, Default)] pub struct Buffer { pub buf: String, - pub indent: usize, + pub indent_level: usize, + pub indent: IndentOptions, } impl Buffer { @@ -31,16 +32,16 @@ impl Buffer { } pub fn tab(&mut self) -> Result { - self.write_tabs(self.indent) + self.write_tabs(self.indent_level) } pub fn indented_tab(&mut self) -> Result { - self.write_tabs(self.indent + 1) + self.write_tabs(self.indent_level + 1) } pub fn write_tabs(&mut self, num: usize) -> std::fmt::Result { for _ in 0..num { - write!(self.buf, " ")? + write!(self.buf, "{}", self.indent.indent_str())? } Ok(()) } diff --git a/packages/autofmt/src/element.rs b/packages/autofmt/src/element.rs index dbc336401..27e7b31f1 100644 --- a/packages/autofmt/src/element.rs +++ b/packages/autofmt/src/element.rs @@ -66,7 +66,7 @@ impl Writer<'_> { // check if we have a lot of attributes let attr_len = self.is_short_attrs(attributes); - let is_short_attr_list = (attr_len + self.out.indent * 4) < 80; + let is_short_attr_list = (attr_len + self.out.indent_level * 4) < 80; let children_len = self.is_short_children(children); let is_small_children = children_len.is_some(); @@ -86,7 +86,7 @@ impl Writer<'_> { // if we have few children and few attributes, make it a one-liner if is_short_attr_list && is_small_children { - if children_len.unwrap() + attr_len + self.out.indent * 4 < 100 { + if children_len.unwrap() + attr_len + self.out.indent_level * 4 < 100 { opt_level = ShortOptimization::Oneliner; } else { opt_level = ShortOptimization::PropsOnTop; @@ -185,11 +185,11 @@ impl Writer<'_> { } while let Some(attr) = attr_iter.next() { - self.out.indent += 1; + self.out.indent_level += 1; if !sameline { self.write_comments(attr.attr.start())?; } - self.out.indent -= 1; + self.out.indent_level -= 1; if !sameline { self.out.indented_tabbed_line()?; @@ -398,14 +398,14 @@ impl Writer<'_> { for idx in start.line..end.line { let line = &self.src[idx]; if line.trim().starts_with("//") { - for _ in 0..self.out.indent + 1 { + for _ in 0..self.out.indent_level + 1 { write!(self.out, " ")? } writeln!(self.out, "{}", line.trim()).unwrap(); } } - for _ in 0..self.out.indent { + for _ in 0..self.out.indent_level { write!(self.out, " ")? } diff --git a/packages/autofmt/src/expr.rs b/packages/autofmt/src/expr.rs index b0257f8c3..b7a93a4fc 100644 --- a/packages/autofmt/src/expr.rs +++ b/packages/autofmt/src/expr.rs @@ -29,7 +29,7 @@ impl Writer<'_> { let first_line = &self.src[start.line - 1]; write!(self.out, "{}", &first_line[start.column - 1..].trim_start())?; - let prev_block_indent_level = crate::leading_whitespaces(first_line) / 4; + let prev_block_indent_level = self.out.indent.count_indents(first_line); for (id, line) in self.src[start.line..end.line].iter().enumerate() { writeln!(self.out)?; @@ -43,9 +43,9 @@ impl Writer<'_> { }; // trim the leading whitespace - let previous_indent = crate::leading_whitespaces(line) / 4; + let previous_indent = self.out.indent.count_indents(line); let offset = previous_indent.saturating_sub(prev_block_indent_level); - let required_indent = self.out.indent + offset; + let required_indent = self.out.indent_level + offset; self.out.write_tabs(required_indent)?; let line = line.trim_start(); diff --git a/packages/autofmt/src/indent.rs b/packages/autofmt/src/indent.rs new file mode 100644 index 000000000..2cce7cf1e --- /dev/null +++ b/packages/autofmt/src/indent.rs @@ -0,0 +1,108 @@ +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum IndentType { + Spaces, + Tabs, +} + +#[derive(Debug, Clone)] +pub struct IndentOptions { + width: usize, + indent_string: String, +} + +impl IndentOptions { + pub fn new(typ: IndentType, width: usize) -> Self { + assert_ne!(width, 0, "Cannot have an indent width of 0"); + Self { + width, + indent_string: match typ { + IndentType::Tabs => "\t".into(), + IndentType::Spaces => " ".repeat(width), + }, + } + } + + /// Gets a string containing one indent worth of whitespace + pub fn indent_str(&self) -> &str { + &self.indent_string + } + + /// Computes the line length in characters, counting tabs as the indent width. + pub fn line_length(&self, line: &str) -> usize { + line.chars() + .map(|ch| if ch == '\t' { self.width } else { 1 }) + .sum() + } + + /// Estimates how many times the line has been indented. + pub fn count_indents(&self, mut line: &str) -> usize { + let mut indent = 0; + while !line.is_empty() { + // Try to count tabs + let num_tabs = line.chars().take_while(|ch| *ch == '\t').count(); + if num_tabs > 0 { + indent += num_tabs; + line = &line[num_tabs..]; + continue; + } + + // Try to count spaces + let num_spaces = line.chars().take_while(|ch| *ch == ' ').count(); + if num_spaces >= self.width { + // Intentionally floor here to take only the amount of space that matches an indent + let num_space_indents = num_spaces / self.width; + indent += num_space_indents; + line = &line[num_space_indents * self.width..]; + continue; + } + + // Line starts with either non-indent characters or an unevent amount of spaces, + // so no more indent remains. + break; + } + indent + } +} + +impl Default for IndentOptions { + fn default() -> Self { + Self::new(IndentType::Spaces, 4) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn count_indents() { + assert_eq!( + IndentOptions::new(IndentType::Spaces, 4).count_indents("no indentation here!"), + 0 + ); + assert_eq!( + IndentOptions::new(IndentType::Spaces, 4).count_indents(" v += 2"), + 1 + ); + assert_eq!( + IndentOptions::new(IndentType::Spaces, 4).count_indents(" v += 2"), + 2 + ); + assert_eq!( + IndentOptions::new(IndentType::Spaces, 4).count_indents(" v += 2"), + 2 + ); + assert_eq!( + IndentOptions::new(IndentType::Spaces, 4).count_indents("\t\tv += 2"), + 2 + ); + assert_eq!( + IndentOptions::new(IndentType::Spaces, 4).count_indents("\t\t v += 2"), + 2 + ); + assert_eq!( + IndentOptions::new(IndentType::Spaces, 2).count_indents(" v += 2"), + 2 + ); + } +} diff --git a/packages/autofmt/src/lib.rs b/packages/autofmt/src/lib.rs index 64a342885..0c3fe6bb8 100644 --- a/packages/autofmt/src/lib.rs +++ b/packages/autofmt/src/lib.rs @@ -16,8 +16,11 @@ mod collect_macros; mod component; mod element; mod expr; +mod indent; mod writer; +pub use indent::{IndentOptions, IndentType}; + /// A modification to the original file to be applied by an IDE /// /// Right now this re-writes entire rsx! blocks at a time, instead of precise line-by-line changes. @@ -47,7 +50,7 @@ pub struct FormattedBlock { /// back to the file precisely. /// /// Nested blocks of RSX will be handled automatically -pub fn fmt_file(contents: &str) -> Vec { +pub fn fmt_file(contents: &str, indent: IndentOptions) -> Vec { let mut formatted_blocks = Vec::new(); let parsed = syn::parse_file(contents).unwrap(); @@ -61,6 +64,7 @@ pub fn fmt_file(contents: &str) -> Vec { } let mut writer = Writer::new(contents); + writer.out.indent = indent; // Don't parse nested macros let mut end_span = LineColumn { column: 0, line: 0 }; @@ -76,7 +80,10 @@ pub fn fmt_file(contents: &str) -> Vec { let rsx_start = macro_path.span().start(); - writer.out.indent = leading_whitespaces(writer.src[rsx_start.line - 1]) / 4; + writer.out.indent_level = writer + .out + .indent + .count_indents(writer.src[rsx_start.line - 1]); write_body(&mut writer, &body); @@ -159,12 +166,13 @@ pub fn fmt_block_from_expr(raw: &str, expr: ExprMacro) -> Option { buf.consume() } -pub fn fmt_block(block: &str, indent_level: usize) -> Option { +pub fn fmt_block(block: &str, indent_level: usize, indent: IndentOptions) -> Option { let body = syn::parse_str::(block).unwrap(); let mut buf = Writer::new(block); - buf.out.indent = indent_level; + buf.out.indent = indent; + buf.out.indent_level = indent_level; write_body(&mut buf, &body); @@ -230,14 +238,3 @@ pub(crate) fn write_ifmt(input: &IfmtInput, writable: &mut impl Write) -> std::f let display = DisplayIfmt(input); write!(writable, "{}", display) } - -pub fn leading_whitespaces(input: &str) -> usize { - input - .chars() - .map_while(|c| match c { - ' ' => Some(1), - '\t' => Some(4), - _ => None, - }) - .sum() -} diff --git a/packages/autofmt/src/writer.rs b/packages/autofmt/src/writer.rs index 59f135592..bb333e8df 100644 --- a/packages/autofmt/src/writer.rs +++ b/packages/autofmt/src/writer.rs @@ -96,11 +96,11 @@ impl<'a> Writer<'a> { // Push out the indent level and write each component, line by line pub fn write_body_indented(&mut self, children: &[BodyNode]) -> Result { - self.out.indent += 1; + self.out.indent_level += 1; self.write_body_no_indent(children)?; - self.out.indent -= 1; + self.out.indent_level -= 1; Ok(()) } diff --git a/packages/autofmt/tests/samples.rs b/packages/autofmt/tests/samples.rs index 8145b0e8e..9431d700e 100644 --- a/packages/autofmt/tests/samples.rs +++ b/packages/autofmt/tests/samples.rs @@ -12,7 +12,7 @@ macro_rules! twoway { #[test] fn $name() { let src = include_str!(concat!("./samples/", stringify!($name), ".rsx")); - let formatted = dioxus_autofmt::fmt_file(src); + let formatted = dioxus_autofmt::fmt_file(src, Default::default()); let out = dioxus_autofmt::apply_formats(src, formatted); // normalize line endings let out = out.replace("\r", ""); diff --git a/packages/autofmt/tests/wrong.rs b/packages/autofmt/tests/wrong.rs index 5a0fb8a87..06a0f00d1 100644 --- a/packages/autofmt/tests/wrong.rs +++ b/packages/autofmt/tests/wrong.rs @@ -1,10 +1,12 @@ +use dioxus_autofmt::{IndentOptions, IndentType}; + macro_rules! twoway { - ($val:literal => $name:ident) => { + ($val:literal => $name:ident ($indent:expr)) => { #[test] fn $name() { let src_right = include_str!(concat!("./wrong/", $val, ".rsx")); let src_wrong = include_str!(concat!("./wrong/", $val, ".wrong.rsx")); - let formatted = dioxus_autofmt::fmt_file(src_wrong); + let formatted = dioxus_autofmt::fmt_file(src_wrong, $indent); let out = dioxus_autofmt::apply_formats(src_wrong, formatted); // normalize line endings @@ -16,8 +18,11 @@ macro_rules! twoway { }; } -twoway!("comments" => comments); +twoway!("comments-4sp" => comments_4sp (IndentOptions::new(IndentType::Spaces, 4))); +twoway!("comments-tab" => comments_tab (IndentOptions::new(IndentType::Tabs, 4))); -twoway!("multi" => multi); +twoway!("multi-4sp" => multi_4sp (IndentOptions::new(IndentType::Spaces, 4))); +twoway!("multi-tab" => multi_tab (IndentOptions::new(IndentType::Tabs, 4))); -twoway!("multiexpr" => multiexpr); +twoway!("multiexpr-4sp" => multiexpr_4sp (IndentOptions::new(IndentType::Spaces, 4))); +twoway!("multiexpr-tab" => multiexpr_tab (IndentOptions::new(IndentType::Tabs, 4))); diff --git a/packages/autofmt/tests/wrong/comments.rsx b/packages/autofmt/tests/wrong/comments-4sp.rsx similarity index 100% rename from packages/autofmt/tests/wrong/comments.rsx rename to packages/autofmt/tests/wrong/comments-4sp.rsx diff --git a/packages/autofmt/tests/wrong/comments.wrong.rsx b/packages/autofmt/tests/wrong/comments-4sp.wrong.rsx similarity index 100% rename from packages/autofmt/tests/wrong/comments.wrong.rsx rename to packages/autofmt/tests/wrong/comments-4sp.wrong.rsx diff --git a/packages/autofmt/tests/wrong/comments-tab.rsx b/packages/autofmt/tests/wrong/comments-tab.rsx new file mode 100644 index 000000000..4c05e347e --- /dev/null +++ b/packages/autofmt/tests/wrong/comments-tab.rsx @@ -0,0 +1,7 @@ +rsx! { + div { + // Comments + class: "asdasd", + "hello world" + } +} diff --git a/packages/autofmt/tests/wrong/comments-tab.wrong.rsx b/packages/autofmt/tests/wrong/comments-tab.wrong.rsx new file mode 100644 index 000000000..eac96642d --- /dev/null +++ b/packages/autofmt/tests/wrong/comments-tab.wrong.rsx @@ -0,0 +1,5 @@ +rsx! { + div { + // Comments + class: "asdasd", "hello world" } +} diff --git a/packages/autofmt/tests/wrong/multi.rsx b/packages/autofmt/tests/wrong/multi-4sp.rsx similarity index 100% rename from packages/autofmt/tests/wrong/multi.rsx rename to packages/autofmt/tests/wrong/multi-4sp.rsx diff --git a/packages/autofmt/tests/wrong/multi.wrong.rsx b/packages/autofmt/tests/wrong/multi-4sp.wrong.rsx similarity index 100% rename from packages/autofmt/tests/wrong/multi.wrong.rsx rename to packages/autofmt/tests/wrong/multi-4sp.wrong.rsx diff --git a/packages/autofmt/tests/wrong/multi-tab.rsx b/packages/autofmt/tests/wrong/multi-tab.rsx new file mode 100644 index 000000000..1fb85c3d0 --- /dev/null +++ b/packages/autofmt/tests/wrong/multi-tab.rsx @@ -0,0 +1,3 @@ +fn app(cx: Scope) -> Element { + cx.render(rsx! { div { "hello world" } }) +} diff --git a/packages/autofmt/tests/wrong/multi-tab.wrong.rsx b/packages/autofmt/tests/wrong/multi-tab.wrong.rsx new file mode 100644 index 000000000..d818f9535 --- /dev/null +++ b/packages/autofmt/tests/wrong/multi-tab.wrong.rsx @@ -0,0 +1,5 @@ +fn app(cx: Scope) -> Element { + cx.render(rsx! { + div {"hello world" } + }) +} diff --git a/packages/autofmt/tests/wrong/multiexpr.rsx b/packages/autofmt/tests/wrong/multiexpr-4sp.rsx similarity index 100% rename from packages/autofmt/tests/wrong/multiexpr.rsx rename to packages/autofmt/tests/wrong/multiexpr-4sp.rsx diff --git a/packages/autofmt/tests/wrong/multiexpr.wrong.rsx b/packages/autofmt/tests/wrong/multiexpr-4sp.wrong.rsx similarity index 100% rename from packages/autofmt/tests/wrong/multiexpr.wrong.rsx rename to packages/autofmt/tests/wrong/multiexpr-4sp.wrong.rsx diff --git a/packages/autofmt/tests/wrong/multiexpr-tab.rsx b/packages/autofmt/tests/wrong/multiexpr-tab.rsx new file mode 100644 index 000000000..1fc3401c4 --- /dev/null +++ b/packages/autofmt/tests/wrong/multiexpr-tab.rsx @@ -0,0 +1,8 @@ +fn ItWroks() { + cx.render(rsx! { + div { class: "flex flex-wrap items-center dark:text-white py-16 border-t font-light", + left, + right + } + }) +} diff --git a/packages/autofmt/tests/wrong/multiexpr-tab.wrong.rsx b/packages/autofmt/tests/wrong/multiexpr-tab.wrong.rsx new file mode 100644 index 000000000..073107541 --- /dev/null +++ b/packages/autofmt/tests/wrong/multiexpr-tab.wrong.rsx @@ -0,0 +1,5 @@ +fn ItWroks() { + cx.render(rsx! { + div { class: "flex flex-wrap items-center dark:text-white py-16 border-t font-light", left, right } + }) +} diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 376a9067d..2cc19f770 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dioxus-cli" -version = "0.4.1" +version = "0.4.3" authors = ["Jonathan Kelley"] edition = "2021" description = "CLI tool for developing, testing, and publishing Dioxus apps" diff --git a/packages/cli/README.md b/packages/cli/README.md index 72a813468..8379f22cc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,7 @@ It handles building, bundling, development and publishing to simplify developmen ### Install the stable version (recommended) ``` -cargo install dioxus-cli --locked +cargo install dioxus-cli ``` ### Install the latest development build through git diff --git a/packages/cli/src/assets/dioxus.toml b/packages/cli/src/assets/dioxus.toml index 6386fb76e..892a6cdf8 100644 --- a/packages/cli/src/assets/dioxus.toml +++ b/packages/cli/src/assets/dioxus.toml @@ -23,7 +23,7 @@ title = "Dioxus | An elegant GUI library for Rust" index_on_404 = true -watch_path = ["src"] +watch_path = ["src", "examples"] # include `assets` in web platform [web.resource] diff --git a/packages/cli/src/builder.rs b/packages/cli/src/builder.rs index 1d9c987e9..3e27a603c 100644 --- a/packages/cli/src/builder.rs +++ b/packages/cli/src/builder.rs @@ -49,14 +49,25 @@ pub fn build(config: &CrateConfig, _: bool, skip_assets: bool) -> Result Result String .unwrap_or_default() .contains_key("tailwindcss") { - style_str.push_str("\n"); + style_str.push_str("\n"); } if !skip_assets { let manifest = config.asset_manifest(); diff --git a/packages/cli/src/cli/autoformat.rs b/packages/cli/src/cli/autoformat.rs index 1d1cbcd24..6c1ce04a1 100644 --- a/packages/cli/src/cli/autoformat.rs +++ b/packages/cli/src/cli/autoformat.rs @@ -1,3 +1,4 @@ +use dioxus_autofmt::{IndentOptions, IndentType}; use futures::{stream::FuturesUnordered, StreamExt}; use std::{fs, path::Path, process::exit}; @@ -35,7 +36,8 @@ impl Autoformat { } if let Some(raw) = self.raw { - if let Some(inner) = dioxus_autofmt::fmt_block(&raw, 0) { + let indent = indentation_for(".")?; + if let Some(inner) = dioxus_autofmt::fmt_block(&raw, 0, indent) { println!("{}", inner); } else { // exit process with error @@ -46,17 +48,21 @@ impl Autoformat { // Format single file if let Some(file) = self.file { - let file_content = if file == "-" { + let file_content; + let indent; + if file == "-" { + indent = indentation_for(".")?; let mut contents = String::new(); std::io::stdin().read_to_string(&mut contents)?; - Ok(contents) + file_content = Ok(contents); } else { - fs::read_to_string(&file) + indent = indentation_for(".")?; + file_content = fs::read_to_string(&file); }; match file_content { Ok(s) => { - let edits = dioxus_autofmt::fmt_file(&s); + let edits = dioxus_autofmt::fmt_file(&s, indent); let out = dioxus_autofmt::apply_formats(&s, edits); if file == "-" { print!("{}", out); @@ -93,6 +99,12 @@ async fn autoformat_project(check: bool) -> Result<()> { let mut files_to_format = vec![]; collect_rs_files(&crate_config.crate_dir, &mut files_to_format); + if files_to_format.is_empty() { + return Ok(()); + } + + let indent = indentation_for(&files_to_format[0])?; + let counts = files_to_format .into_iter() .filter(|file| { @@ -104,10 +116,11 @@ async fn autoformat_project(check: bool) -> Result<()> { }) .map(|path| async { let _path = path.clone(); + let _indent = indent.clone(); let res = tokio::spawn(async move { let contents = tokio::fs::read_to_string(&path).await?; - let edits = dioxus_autofmt::fmt_file(&contents); + let edits = dioxus_autofmt::fmt_file(&contents, _indent.clone()); let len = edits.len(); if !edits.is_empty() { @@ -151,6 +164,49 @@ async fn autoformat_project(check: bool) -> Result<()> { Ok(()) } +fn indentation_for(file_or_dir: impl AsRef) -> Result { + let out = std::process::Command::new("cargo") + .args(["fmt", "--", "--print-config", "current"]) + .arg(file_or_dir.as_ref()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .output()?; + if !out.status.success() { + return Err(Error::CargoError("cargo fmt failed".into())); + } + + let config = String::from_utf8_lossy(&out.stdout); + + let hard_tabs = config + .lines() + .find(|line| line.starts_with("hard_tabs ")) + .and_then(|line| line.split_once('=')) + .map(|(_, value)| value.trim() == "true") + .ok_or_else(|| { + Error::RuntimeError("Could not find hard_tabs option in rustfmt config".into()) + })?; + let tab_spaces = config + .lines() + .find(|line| line.starts_with("tab_spaces ")) + .and_then(|line| line.split_once('=')) + .map(|(_, value)| value.trim().parse::()) + .ok_or_else(|| { + Error::RuntimeError("Could not find tab_spaces option in rustfmt config".into()) + })? + .map_err(|_| { + Error::RuntimeError("Could not parse tab_spaces option in rustfmt config".into()) + })?; + + Ok(IndentOptions::new( + if hard_tabs { + IndentType::Tabs + } else { + IndentType::Spaces + }, + tab_spaces, + )) +} + fn collect_rs_files(folder: &Path, files: &mut Vec) { let Ok(folder) = folder.read_dir() else { return; diff --git a/packages/cli/src/cli/build.rs b/packages/cli/src/cli/build.rs index 72cdc1bfd..e1e8ac607 100644 --- a/packages/cli/src/cli/build.rs +++ b/packages/cli/src/cli/build.rs @@ -39,8 +39,8 @@ impl Build { .platform .unwrap_or(crate_config.dioxus_config.application.default_platform); - #[cfg(feature = "plugin")] - let _ = PluginManager::on_build_start(&crate_config, &platform); + // #[cfg(feature = "plugin")] + // let _ = PluginManager::on_build_start(&crate_config, &platform); match platform { Platform::Web => { @@ -98,8 +98,8 @@ impl Build { )?; file.write_all(temp.as_bytes())?; - #[cfg(feature = "plugin")] - let _ = PluginManager::on_build_finish(&crate_config, &platform); + // #[cfg(feature = "plugin")] + // let _ = PluginManager::on_build_finish(&crate_config, &platform); Ok(()) } diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs index 97c1557ba..297ece53a 100644 --- a/packages/cli/src/config.rs +++ b/packages/cli/src/config.rs @@ -107,7 +107,7 @@ impl Default for DioxusConfig { }, proxy: Some(vec![]), watcher: WebWatcherConfig { - watch_path: Some(vec![PathBuf::from("src")]), + watch_path: Some(vec![PathBuf::from("src"), PathBuf::from("examples")]), reload_html: Some(false), index_on_404: Some(true), }, diff --git a/packages/cli/src/error.rs b/packages/cli/src/error.rs index 84b9d4b71..d577b2b40 100644 --- a/packages/cli/src/error.rs +++ b/packages/cli/src/error.rs @@ -29,6 +29,9 @@ pub enum Error { #[error("Cargo Error: {0}")] CargoError(String), + #[error("Couldn't retrieve cargo metadata")] + CargoMetadata(#[source] cargo_metadata::Error), + #[error("{0}")] CustomError(String), diff --git a/packages/cli/src/logging.rs b/packages/cli/src/logging.rs index 48c2a2788..31d088737 100644 --- a/packages/cli/src/logging.rs +++ b/packages/cli/src/logging.rs @@ -28,7 +28,19 @@ pub fn set_up_logging() { message = message, )); }) - .level(log::LevelFilter::Info) + .level(match std::env::var("DIOXUS_LOG") { + Ok(level) => match level.to_lowercase().as_str() { + "error" => log::LevelFilter::Error, + "warn" => log::LevelFilter::Warn, + "info" => log::LevelFilter::Info, + "debug" => log::LevelFilter::Debug, + "trace" => log::LevelFilter::Trace, + _ => { + panic!("Invalid log level: {}", level) + } + }, + Err(_) => log::LevelFilter::Info, + }) .chain(std::io::stdout()) .apply() .unwrap(); diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index fe860a0e6..7ca1b2c9a 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -9,42 +9,31 @@ use dioxus_cli::plugin::PluginManager; use Commands::*; -fn get_bin(bin: Option) -> Result> { - const ERR_MESSAGE: &str = "The `--bin` flag has to be ran in a Cargo workspace."; +fn get_bin(bin: Option) -> Result { + let metadata = cargo_metadata::MetadataCommand::new() + .exec() + .map_err(Error::CargoMetadata)?; + let package = if let Some(bin) = bin { + metadata + .workspace_packages() + .into_iter() + .find(|p| p.name == bin) + .ok_or(format!("no such package: {}", bin)) + .map_err(Error::CargoError)? + } else { + metadata + .root_package() + .ok_or("no root package?".into()) + .map_err(Error::CargoError)? + }; - if let Some(ref bin) = bin { - let manifest = cargo_toml::Manifest::from_path("./Cargo.toml") - .map_err(|_| Error::CargoError(ERR_MESSAGE.to_string()))?; + let crate_dir = package + .manifest_path + .parent() + .ok_or("couldn't take parent dir".into()) + .map_err(Error::CargoError)?; - if let Some(workspace) = manifest.workspace { - for item in workspace.members.iter() { - let path = PathBuf::from(item); - - if !path.exists() { - continue; - } - - if !path.is_dir() { - continue; - } - - if path.ends_with(bin.clone()) { - return Ok(Some(path)); - } - } - } else { - return Err(Error::CargoError(ERR_MESSAGE.to_string())); - } - } - - // If the bin exists but we couldn't find it - if bin.is_some() { - return Err(Error::CargoError( - "The specified bin does not exist.".to_string(), - )); - } - - Ok(None) + Ok(crate_dir.into()) } #[tokio::main] @@ -55,7 +44,7 @@ async fn main() -> anyhow::Result<()> { let bin = get_bin(args.bin)?; - let _dioxus_config = DioxusConfig::load(bin.clone()) + let _dioxus_config = DioxusConfig::load(Some(bin.clone())) .map_err(|e| anyhow!("Failed to load Dioxus config because: {e}"))? .unwrap_or_else(|| { log::warn!("You appear to be creating a Dioxus project from scratch; we will use the default config"); @@ -72,15 +61,15 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow!("🚫 Translation of HTML into RSX failed: {}", e)), Build(opts) => opts - .build(bin.clone()) + .build(Some(bin.clone())) .map_err(|e| anyhow!("🚫 Building project failed: {}", e)), Clean(opts) => opts - .clean(bin.clone()) + .clean(Some(bin.clone())) .map_err(|e| anyhow!("🚫 Cleaning project failed: {}", e)), Serve(opts) => opts - .serve(bin.clone()) + .serve(Some(bin.clone())) .await .map_err(|e| anyhow!("🚫 Serving project failed: {}", e)), @@ -93,7 +82,7 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow!("🚫 Configuring new project failed: {}", e)), Bundle(opts) => opts - .bundle(bin.clone()) + .bundle(Some(bin.clone())) .map_err(|e| anyhow!("🚫 Bundling project failed: {}", e)), #[cfg(feature = "plugin")] diff --git a/packages/cli/src/server/desktop/mod.rs b/packages/cli/src/server/desktop/mod.rs index 8922489b5..a40667696 100644 --- a/packages/cli/src/server/desktop/mod.rs +++ b/packages/cli/src/server/desktop/mod.rs @@ -51,8 +51,6 @@ pub(crate) async fn startup_with_platform( let hot_reload_tx = broadcast::channel(100).0; - clear_paths(); - Some(HotReloadState { messages: hot_reload_tx.clone(), file_map: file_map.clone(), @@ -103,7 +101,14 @@ async fn serve( } async fn start_desktop_hot_reload(hot_reload_state: HotReloadState) -> Result<()> { - match LocalSocketListener::bind("@dioxusin") { + let metadata = cargo_metadata::MetadataCommand::new() + .no_deps() + .exec() + .unwrap(); + let target_dir = metadata.target_directory.as_std_path(); + let path = target_dir.join("dioxusin"); + clear_paths(&path); + match LocalSocketListener::bind(path) { Ok(local_socket_stream) => { let aborted = Arc::new(Mutex::new(false)); // States @@ -115,9 +120,9 @@ async fn start_desktop_hot_reload(hot_reload_state: HotReloadState) -> Result<() let file_map = hot_reload_state.file_map.clone(); let channels = channels.clone(); let aborted = aborted.clone(); - let _ = local_socket_stream.set_nonblocking(true); move || { loop { + //accept() will block the thread when local_socket_stream is in blocking mode (default) match local_socket_stream.accept() { Ok(mut connection) => { // send any templates than have changed before the socket connected @@ -175,17 +180,14 @@ async fn start_desktop_hot_reload(hot_reload_state: HotReloadState) -> Result<() Ok(()) } -fn clear_paths() { +fn clear_paths(file_socket_path: &std::path::Path) { if cfg!(target_os = "macos") { // On unix, if you force quit the application, it can leave the file socket open // This will cause the local socket listener to fail to open // We check if the file socket is already open from an old session and then delete it - let paths = ["./dioxusin", "./@dioxusin"]; - for path in paths { - let path = std::path::PathBuf::from(path); - if path.exists() { - let _ = std::fs::remove_file(path); - } + + if file_socket_path.exists() { + let _ = std::fs::remove_file(file_socket_path); } } } diff --git a/packages/cli/src/server/mod.rs b/packages/cli/src/server/mod.rs index 8badab929..895d9e51e 100644 --- a/packages/cli/src/server/mod.rs +++ b/packages/cli/src/server/mod.rs @@ -33,7 +33,7 @@ async fn setup_file_watcher Result + Send + 'static>( .watcher .watch_path .clone() - .unwrap_or_else(|| vec![PathBuf::from("src")]); + .unwrap_or_else(|| vec![PathBuf::from("src"), PathBuf::from("examples")]); let watcher_config = config.clone(); let mut watcher = notify::recommended_watcher(move |info: notify::Result| { @@ -56,6 +56,16 @@ async fn setup_file_watcher Result + Send + 'static>( break; } + // Workaround for notify and vscode-like editor: + // when edit & save a file in vscode, there will be two notifications, + // the first one is a file with empty content. + // filter the empty file notification to avoid false rebuild during hot-reload + if let Ok(metadata) = fs::metadata(path) { + if metadata.len() == 0 { + continue; + } + } + match rsx_file_map.update_rsx(path, &config.crate_dir) { Ok(UpdateResult::UpdatedRsx(msgs)) => { messages.extend(msgs); @@ -122,12 +132,12 @@ async fn setup_file_watcher Result + Send + 'static>( .unwrap(); for sub_path in allow_watch_path { - watcher - .watch( - &config.crate_dir.join(sub_path), - notify::RecursiveMode::Recursive, - ) - .unwrap(); + if let Err(err) = watcher.watch( + &config.crate_dir.join(sub_path), + notify::RecursiveMode::Recursive, + ) { + log::error!("Failed to watch path: {}", err); + } } Ok(watcher) } diff --git a/packages/cli/src/server/output.rs b/packages/cli/src/server/output.rs index 71323dd29..4e148b0f1 100644 --- a/packages/cli/src/server/output.rs +++ b/packages/cli/src/server/output.rs @@ -22,17 +22,20 @@ pub fn print_console_info( options: PrettierOptions, web_info: Option, ) { - if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") { - "cls" - } else { - "clear" - }) - .output() - { - print!("{}", String::from_utf8_lossy(&native_clearseq.stdout)); - } else { - // Try ANSI-Escape characters - print!("\x1b[2J\x1b[H"); + // Don't clear the screen if the user has set the DIOXUS_LOG environment variable to "trace" so that we can see the logs + if Some("trace") != std::env::var("DIOXUS_LOG").ok().as_deref() { + if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") { + "cls" + } else { + "clear" + }) + .output() + { + print!("{}", String::from_utf8_lossy(&native_clearseq.stdout)); + } else { + // Try ANSI-Escape characters + print!("\x1b[2J\x1b[H"); + } } let mut profile = if config.release { "Release" } else { "Debug" }.to_string(); diff --git a/packages/core-macro/Cargo.toml b/packages/core-macro/Cargo.toml index d928d5fec..1898d8bd1 100644 --- a/packages/core-macro/Cargo.toml +++ b/packages/core-macro/Cargo.toml @@ -19,6 +19,7 @@ syn = { version = "2.0", features = ["full", "extra-traits"] } dioxus-rsx = { workspace = true } dioxus-core = { workspace = true } constcat = "0.3.0" +prettyplease = "0.2.15" # testing [dev-dependencies] diff --git a/packages/core-macro/src/component_body_deserializers/component.rs b/packages/core-macro/src/component_body_deserializers/component.rs index e43b1819f..36cda850c 100644 --- a/packages/core-macro/src/component_body_deserializers/component.rs +++ b/packages/core-macro/src/component_body_deserializers/component.rs @@ -31,6 +31,7 @@ fn get_out_comp_fn(orig_comp_fn: &ItemFn, cx_pat: &Pat) -> ItemFn { block: parse_quote! { { #[warn(non_snake_case)] + #[allow(clippy::inline_always)] #[inline(always)] #inner_comp_fn #inner_comp_ident (#cx_pat) diff --git a/packages/core-macro/src/component_body_deserializers/inline_props.rs b/packages/core-macro/src/component_body_deserializers/inline_props.rs index 8e4581c19..d51132623 100644 --- a/packages/core-macro/src/component_body_deserializers/inline_props.rs +++ b/packages/core-macro/src/component_body_deserializers/inline_props.rs @@ -30,166 +30,312 @@ impl ToTokens for InlinePropsDeserializerOutput { impl DeserializerArgs for InlinePropsDeserializerArgs { fn to_output(&self, component_body: &ComponentBody) -> Result { Ok(InlinePropsDeserializerOutput { - comp_fn: Self::get_function(component_body), - props_struct: Self::get_props_struct(component_body), + comp_fn: get_function(component_body), + props_struct: get_props_struct(component_body), }) } } -impl InlinePropsDeserializerArgs { - fn get_props_struct(component_body: &ComponentBody) -> ItemStruct { - let ComponentBody { item_fn, .. } = component_body; - let ItemFn { vis, sig, .. } = item_fn; - let Signature { - inputs, - ident: fn_ident, - generics, - .. - } = sig; +fn get_props_struct(component_body: &ComponentBody) -> ItemStruct { + let ComponentBody { item_fn, .. } = component_body; + let ItemFn { vis, sig, .. } = item_fn; + let Signature { + inputs, + ident: fn_ident, + generics, + .. + } = sig; - // Skip first arg since that's the context - let struct_fields = inputs.iter().skip(1).map(move |f| { - match f { - FnArg::Receiver(_) => unreachable!(), // Unreachable because of ComponentBody parsing - FnArg::Typed(pt) => { - let arg_pat = &pt.pat; // Pattern (identifier) - let arg_colon = &pt.colon_token; - let arg_ty = &pt.ty; // Type - let arg_attrs = &pt.attrs; // Attributes + // Skip first arg since that's the context + let struct_fields = inputs.iter().skip(1).map(move |f| { + match f { + FnArg::Receiver(_) => unreachable!(), // Unreachable because of ComponentBody parsing + FnArg::Typed(pt) => { + let arg_pat = &pt.pat; // Pattern (identifier) + let arg_colon = &pt.colon_token; + let arg_ty = &pt.ty; // Type + let arg_attrs = &pt.attrs; // Attributes - quote! { - #(#arg_attrs) - * - #vis #arg_pat #arg_colon #arg_ty - } + quote! { + #(#arg_attrs) + * + #vis #arg_pat #arg_colon #arg_ty } } - }); - - let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); - - let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { - Some(lt) - } else { - None - }; - - let struct_attrs = if first_lifetime.is_some() { - quote! { #[derive(Props)] } - } else { - quote! { #[derive(Props, PartialEq)] } - }; - - let struct_generics = if first_lifetime.is_some() { - let struct_generics: Punctuated = component_body - .item_fn - .sig - .generics - .params - .iter() - .map(|it| match it { - GenericParam::Type(tp) => { - let mut tp = tp.clone(); - tp.bounds.push(parse_quote!( 'a )); - - GenericParam::Type(tp) - } - _ => it.clone(), - }) - .collect(); - - quote! { <#struct_generics> } - } else { - quote! { #generics } - }; - - parse_quote! { - #struct_attrs - #[allow(non_camel_case_types)] - #vis struct #struct_ident #struct_generics - { - #(#struct_fields),* - } } - } + }); - fn get_function(component_body: &ComponentBody) -> ItemFn { - let ComponentBody { - item_fn, - cx_pat_type, - .. - } = component_body; - let ItemFn { - attrs: fn_attrs, - vis, - sig, - block: fn_block, - } = item_fn; - let Signature { - inputs, - ident: fn_ident, - generics, - output: fn_output, - asyncness, - .. - } = sig; - let Generics { where_clause, .. } = generics; + let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); - let cx_pat = &cx_pat_type.pat; - let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); + let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { + Some(lt) + } else { + None + }; - // Skip first arg since that's the context - let struct_field_names = inputs.iter().skip(1).filter_map(|f| match f { - FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters. - FnArg::Typed(t) => Some(&t.pat), - }); + let struct_attrs = if first_lifetime.is_some() { + quote! { #[derive(Props)] } + } else { + quote! { #[derive(Props, PartialEq)] } + }; - let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { - Some(lt) - } else { - None - }; + let struct_generics = if first_lifetime.is_some() { + let struct_generics: Punctuated = component_body + .item_fn + .sig + .generics + .params + .iter() + .map(|it| match it { + GenericParam::Type(tp) => { + let mut tp = tp.clone(); + tp.bounds.push(parse_quote!( 'a )); - let (scope_lifetime, fn_generics) = if let Some(lt) = first_lifetime { - (quote! { #lt, }, generics.clone()) - } else { - let lifetime: LifetimeParam = parse_quote! { 'a }; + GenericParam::Type(tp) + } + _ => it.clone(), + }) + .collect(); - let mut fn_generics = generics.clone(); - fn_generics - .params - .insert(0, GenericParam::Lifetime(lifetime.clone())); + quote! { <#struct_generics> } + } else { + quote! { #generics } + }; - (quote! { #lifetime, }, fn_generics) - }; - - let generics_no_bounds = { - let mut generics = generics.clone(); - generics.params = generics - .params - .iter() - .map(|it| match it { - GenericParam::Type(tp) => { - let mut tp = tp.clone(); - tp.bounds.clear(); - - GenericParam::Type(tp) - } - _ => it.clone(), - }) - .collect(); - - generics - }; - - parse_quote! { - #(#fn_attrs)* - #asyncness #vis fn #fn_ident #fn_generics (#cx_pat: Scope<#scope_lifetime #struct_ident #generics_no_bounds>) #fn_output - #where_clause - { - let #struct_ident { #(#struct_field_names),* } = &#cx_pat.props; - #fn_block - } + parse_quote! { + #struct_attrs + #[allow(non_camel_case_types)] + #vis struct #struct_ident #struct_generics + { + #(#struct_fields),* } } } + +fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec { + if inputs.len() <= 1 { + return Vec::new(); + } + + let arg_docs = inputs + .iter() + .filter_map(|f| match f { + FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters. + FnArg::Typed(pt) => { + let arg_doc = pt + .attrs + .iter() + .filter_map(|attr| { + // TODO: Error reporting + // Check if the path of the attribute is "doc" + if !is_attr_doc(attr) { + return None; + }; + + let Meta::NameValue(meta_name_value) = &attr.meta else { + return None; + }; + + let Expr::Lit(doc_lit) = &meta_name_value.value else { + return None; + }; + + let Lit::Str(doc_lit_str) = &doc_lit.lit else { + return None; + }; + + Some(doc_lit_str.value()) + }) + .fold(String::new(), |mut doc, next_doc_line| { + doc.push('\n'); + doc.push_str(&next_doc_line); + doc + }); + + Some(( + &pt.pat, + &pt.ty, + pt.attrs.iter().find_map(|attr| { + if attr.path() != &parse_quote!(deprecated) { + return None; + } + + let res = crate::utils::DeprecatedAttribute::from_meta(&attr.meta); + + match res { + Err(e) => panic!("{}", e.to_string()), + Ok(v) => Some(v), + } + }), + arg_doc, + )) + } + }) + .collect::>(); + + let mut props_docs = Vec::with_capacity(5); + let props_def_link = fn_ident.to_string() + "Props"; + let header = + format!("# Props\n*For details, see the [props struct definition]({props_def_link}).*"); + + props_docs.push(parse_quote! { + #[doc = #header] + }); + + for (arg_name, arg_type, deprecation, input_arg_doc) in arg_docs { + let arg_name = arg_name.into_token_stream().to_string(); + let arg_type = crate::utils::format_type_string(arg_type); + + let input_arg_doc = keep_up_to_n_consecutive_chars(input_arg_doc.trim(), 2, '\n') + .replace("\n\n", "

"); + let prop_def_link = format!("{props_def_link}::{arg_name}"); + let mut arg_doc = format!("- [`{arg_name}`]({prop_def_link}) : `{arg_type}`"); + + if let Some(deprecation) = deprecation { + arg_doc.push_str("

👎 Deprecated"); + + if let Some(since) = deprecation.since { + arg_doc.push_str(&format!(" since {since}")); + } + + if let Some(note) = deprecation.note { + let note = keep_up_to_n_consecutive_chars(¬e, 1, '\n').replace('\n', " "); + let note = keep_up_to_n_consecutive_chars(¬e, 1, '\t').replace('\t', " "); + + arg_doc.push_str(&format!(": {note}")); + } + + arg_doc.push_str("

"); + + if !input_arg_doc.is_empty() { + arg_doc.push_str("
"); + } + } + + if !input_arg_doc.is_empty() { + arg_doc.push_str(&format!("

{input_arg_doc}

")); + } + + props_docs.push(parse_quote! { + #[doc = #arg_doc] + }); + } + + props_docs +} + +fn get_function(component_body: &ComponentBody) -> ItemFn { + let ComponentBody { + item_fn, + cx_pat_type, + .. + } = component_body; + let ItemFn { + attrs: fn_attrs, + vis, + sig, + block: fn_block, + } = item_fn; + let Signature { + inputs, + ident: fn_ident, + generics, + output: fn_output, + asyncness, + .. + } = sig; + let Generics { where_clause, .. } = generics; + + let cx_pat = &cx_pat_type.pat; + let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); + + // Skip first arg since that's the context + let struct_field_names = inputs.iter().skip(1).filter_map(|f| match f { + FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters. + FnArg::Typed(pt) => Some(&pt.pat), + }); + + let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { + Some(lt) + } else { + None + }; + + let (scope_lifetime, fn_generics) = if let Some(lt) = first_lifetime { + (quote! { #lt, }, generics.clone()) + } else { + let lifetime: LifetimeParam = parse_quote! { 'a }; + + let mut fn_generics = generics.clone(); + fn_generics + .params + .insert(0, GenericParam::Lifetime(lifetime.clone())); + + (quote! { #lifetime, }, fn_generics) + }; + + let generics_no_bounds = { + let mut generics = generics.clone(); + generics.params = generics + .params + .iter() + .map(|it| match it { + GenericParam::Type(tp) => { + let mut tp = tp.clone(); + tp.bounds.clear(); + + GenericParam::Type(tp) + } + _ => it.clone(), + }) + .collect(); + + generics + }; + + let props_docs = get_props_docs(fn_ident, inputs.iter().skip(1).collect()); + + parse_quote! { + #(#fn_attrs)* + #(#props_docs)* + #asyncness #vis fn #fn_ident #fn_generics (#cx_pat: Scope<#scope_lifetime #struct_ident #generics_no_bounds>) #fn_output + #where_clause + { + let #struct_ident { #(#struct_field_names),* } = &#cx_pat.props; + #fn_block + } + } +} + +/// Checks if the attribute is a `#[doc]` attribute. +fn is_attr_doc(attr: &Attribute) -> bool { + attr.path() == &parse_quote!(doc) +} + +fn keep_up_to_n_consecutive_chars( + input: &str, + n_of_consecutive_chars_allowed: usize, + target_char: char, +) -> String { + let mut output = String::new(); + let mut prev_char: Option = None; + let mut consecutive_count = 0; + + for c in input.chars() { + match prev_char { + Some(prev) if c == target_char && prev == target_char => { + if consecutive_count < n_of_consecutive_chars_allowed { + output.push(c); + consecutive_count += 1; + } + } + _ => { + output.push(c); + prev_char = Some(c); + consecutive_count = 1; + } + } + } + + output +} diff --git a/packages/core-macro/src/lib.rs b/packages/core-macro/src/lib.rs index 477c05d76..4686bc797 100644 --- a/packages/core-macro/src/lib.rs +++ b/packages/core-macro/src/lib.rs @@ -12,6 +12,7 @@ use syn::{parse_macro_input, Path, Token}; mod component_body; mod component_body_deserializers; mod props; +mod utils; // mod rsx; use crate::component_body::ComponentBody; diff --git a/packages/core-macro/src/props/mod.rs b/packages/core-macro/src/props/mod.rs index c1931e497..d7a580705 100644 --- a/packages/core-macro/src/props/mod.rs +++ b/packages/core-macro/src/props/mod.rs @@ -243,10 +243,6 @@ mod field_info { } .into() } - - pub fn type_from_inside_option(&self, check_option_name: bool) -> Option<&syn::Type> { - type_from_inside_option(self.ty, check_option_name) - } } #[derive(Debug, Default, Clone)] @@ -551,18 +547,16 @@ mod struct_info { let generics_with_empty = modify_types_generics_hack(&ty_generics, |args| { args.insert(0, syn::GenericArgument::Type(empties_tuple.clone().into())); }); - let phantom_generics = self.generics.params.iter().map(|param| match param { + let phantom_generics = self.generics.params.iter().filter_map(|param| match param { syn::GenericParam::Lifetime(lifetime) => { let lifetime = &lifetime.lifetime; - quote!(::core::marker::PhantomData<&#lifetime ()>) + Some(quote!(::core::marker::PhantomData<&#lifetime ()>)) } syn::GenericParam::Type(ty) => { let ty = &ty.ident; - quote!(::core::marker::PhantomData<#ty>) - } - syn::GenericParam::Const(_cnst) => { - quote!() + Some(quote!(::core::marker::PhantomData<#ty>)) } + syn::GenericParam::Const(_cnst) => None, }); let builder_method_doc = match self.builder_attr.builder_method_doc { Some(ref doc) => quote!(#doc), @@ -633,7 +627,7 @@ Finally, call `.build()` to create the instance of `{name}`. Ok(quote! { impl #impl_generics #name #ty_generics #where_clause { #[doc = #builder_method_doc] - #[allow(dead_code)] + #[allow(dead_code, clippy::type_complexity)] #vis fn builder() -> #builder_name #generics_with_empty { #builder_name { fields: #empties_tuple, @@ -701,6 +695,14 @@ Finally, call `.build()` to create the instance of `{name}`. } pub fn field_impl(&self, field: &FieldInfo) -> Result { + let FieldInfo { + name: field_name, + ty: field_type, + .. + } = field; + if *field_name == "key" { + return Err(Error::new_spanned(field_name, "Naming a prop `key` is not allowed because the name can conflict with the built in key attribute. See https://dioxuslabs.com/learn/0.4/reference/dynamic_rendering#rendering-lists for more information about keys")); + } let StructInfo { ref builder_name, .. } = *self; @@ -715,11 +717,6 @@ Finally, call `.build()` to create the instance of `{name}`. }); let reconstructing = self.included_fields().map(|f| f.name); - let FieldInfo { - name: field_name, - ty: field_type, - .. - } = field; let mut ty_generics: Vec = self .generics .params @@ -782,31 +779,16 @@ Finally, call `.build()` to create the instance of `{name}`. None => quote!(), }; - // NOTE: both auto_into and strip_option affect `arg_type` and `arg_expr`, but the order of - // nesting is different so we have to do this little dance. - let arg_type = if field.builder_attr.strip_option { - field.type_from_inside_option(false).ok_or_else(|| { - Error::new_spanned( - field_type, - "can't `strip_option` - field is not `Option<...>`", + let arg_type = field_type; + let (arg_type, arg_expr) = + if field.builder_attr.auto_into || field.builder_attr.strip_option { + ( + quote!(impl ::core::convert::Into<#arg_type>), + quote!(#field_name.into()), ) - })? - } else { - field_type - }; - let (arg_type, arg_expr) = if field.builder_attr.auto_into { - ( - quote!(impl ::core::convert::Into<#arg_type>), - quote!(#field_name.into()), - ) - } else { - (quote!(#arg_type), quote!(#field_name)) - }; - let arg_expr = if field.builder_attr.strip_option { - quote!(Some(#arg_expr)) - } else { - arg_expr - }; + } else { + (quote!(#arg_type), quote!(#field_name)) + }; let repeated_fields_error_type_name = syn::Ident::new( &format!( @@ -822,6 +804,7 @@ Finally, call `.build()` to create the instance of `{name}`. #[allow(dead_code, non_camel_case_types, missing_docs)] impl #impl_generics #builder_name < #( #ty_generics ),* > #where_clause { #doc + #[allow(clippy::type_complexity)] pub fn #field_name (self, #field_name: #arg_type) -> #builder_name < #( #target_generics ),* > { let #field_name = (#arg_expr,); let ( #(#descructuring,)* ) = self.fields; @@ -840,6 +823,7 @@ Finally, call `.build()` to create the instance of `{name}`. #[deprecated( note = #repeated_fields_error_message )] + #[allow(clippy::type_complexity)] pub fn #field_name (self, _: #repeated_fields_error_type_name) -> #builder_name < #( #target_generics ),* > { self } diff --git a/packages/core-macro/src/utils.rs b/packages/core-macro/src/utils.rs new file mode 100644 index 000000000..7e066f773 --- /dev/null +++ b/packages/core-macro/src/utils.rs @@ -0,0 +1,129 @@ +use quote::ToTokens; +use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; +use syn::{parse_quote, Expr, Lit, Meta, Token, Type}; + +const FORMATTED_TYPE_START: &str = "static TY_AFTER_HERE:"; +const FORMATTED_TYPE_END: &str = "= todo!();"; + +/// Attempts to convert the given literal to a string. +/// Converts ints and floats to their base 10 counterparts. +/// +/// Returns `None` if the literal is [`Lit::Verbatim`] or if the literal is [`Lit::ByteStr`] +/// and the byte string could not be converted to UTF-8. +pub fn lit_to_string(lit: Lit) -> Option { + match lit { + Lit::Str(l) => Some(l.value()), + Lit::ByteStr(l) => String::from_utf8(l.value()).ok(), + Lit::Byte(l) => Some(String::from(l.value() as char)), + Lit::Char(l) => Some(l.value().to_string()), + Lit::Int(l) => Some(l.base10_digits().to_string()), + Lit::Float(l) => Some(l.base10_digits().to_string()), + Lit::Bool(l) => Some(l.value().to_string()), + Lit::Verbatim(_) => None, + _ => None, + } +} + +pub fn format_type_string(ty: &Type) -> String { + let ty_unformatted = ty.into_token_stream().to_string(); + let ty_unformatted = ty_unformatted.trim(); + + // This should always be valid syntax. + // Not Rust code, but syntax, which is the only thing that `syn` cares about. + let Ok(file_unformatted) = syn::parse_file(&format!( + "{FORMATTED_TYPE_START}{ty_unformatted}{FORMATTED_TYPE_END}" + )) else { + return ty_unformatted.to_string(); + }; + + let file_formatted = prettyplease::unparse(&file_unformatted); + + let file_trimmed = file_formatted.trim(); + let start_removed = file_trimmed.trim_start_matches(FORMATTED_TYPE_START); + let end_removed = start_removed.trim_end_matches(FORMATTED_TYPE_END); + let ty_formatted = end_removed.trim(); + + ty_formatted.to_string() +} + +/// Represents the `#[deprecated]` attribute. +/// +/// You can use the [`DeprecatedAttribute::from_meta`] function to try to parse an attribute to this struct. +#[derive(Default)] +pub struct DeprecatedAttribute { + pub since: Option, + pub note: Option, +} + +impl DeprecatedAttribute { + /// Returns `None` if the given attribute was not a valid form of the `#[deprecated]` attribute. + pub fn from_meta(meta: &Meta) -> syn::Result { + if meta.path() != &parse_quote!(deprecated) { + return Err(syn::Error::new( + meta.span(), + "attribute path is not `deprecated`", + )); + } + + match &meta { + Meta::Path(_) => Ok(Self::default()), + Meta::NameValue(name_value) => { + let Expr::Lit(expr_lit) = &name_value.value else { + return Err(syn::Error::new( + name_value.span(), + "literal in `deprecated` value must be a string", + )); + }; + + Ok(Self { + since: None, + note: lit_to_string(expr_lit.lit.clone()).map(|s| s.trim().to_string()), + }) + } + Meta::List(list) => { + let parsed = list.parse_args::()?; + + Ok(Self { + since: parsed.since.map(|s| s.trim().to_string()), + note: parsed.note.map(|s| s.trim().to_string()), + }) + } + } + } +} + +mod kw { + use syn::custom_keyword; + custom_keyword!(since); + custom_keyword!(note); +} + +struct DeprecatedAttributeArgsParser { + since: Option, + note: Option, +} + +impl Parse for DeprecatedAttributeArgsParser { + fn parse(input: ParseStream) -> syn::Result { + let mut since: Option = None; + let mut note: Option = None; + + if input.peek(kw::since) { + input.parse::()?; + input.parse::()?; + + since = lit_to_string(input.parse()?); + } + + if input.peek(Token![,]) && input.peek2(kw::note) { + input.parse::()?; + input.parse::()?; + input.parse::()?; + + note = lit_to_string(input.parse()?); + } + + Ok(Self { since, note }) + } +} diff --git a/packages/core/src/arena.rs b/packages/core/src/arena.rs index b7f768755..785d4b174 100644 --- a/packages/core/src/arena.rs +++ b/packages/core/src/arena.rs @@ -164,17 +164,11 @@ impl VirtualDom { }); // Now that all the references are gone, we can safely drop our own references in our listeners. - let mut listeners = scope.attributes_to_drop.borrow_mut(); + let mut listeners = scope.attributes_to_drop_before_render.borrow_mut(); listeners.drain(..).for_each(|listener| { let listener = unsafe { &*listener }; - match &listener.value { - AttributeValue::Listener(l) => { - _ = l.take(); - } - AttributeValue::Any(a) => { - _ = a.take(); - } - _ => (), + if let AttributeValue::Listener(l) = &listener.value { + _ = l.take(); } }); } diff --git a/packages/core/src/bump_frame.rs b/packages/core/src/bump_frame.rs index 0fe7b3867..6927bd8bc 100644 --- a/packages/core/src/bump_frame.rs +++ b/packages/core/src/bump_frame.rs @@ -1,10 +1,16 @@ use crate::nodes::RenderReturn; +use crate::{Attribute, AttributeValue, VComponent}; use bumpalo::Bump; +use std::cell::RefCell; use std::cell::{Cell, UnsafeCell}; pub(crate) struct BumpFrame { pub bump: UnsafeCell, pub node: Cell<*const RenderReturn<'static>>, + + // The bump allocator will not call the destructor of the objects it allocated. Attributes and props need to have there destructor called, so we keep a list of them to drop before the bump allocator is reset. + pub(crate) attributes_to_drop_before_reset: RefCell>>, + pub(crate) props_to_drop_before_reset: RefCell>>, } impl BumpFrame { @@ -13,6 +19,8 @@ impl BumpFrame { Self { bump: UnsafeCell::new(bump), node: Cell::new(std::ptr::null()), + attributes_to_drop_before_reset: Default::default(), + props_to_drop_before_reset: Default::default(), } } @@ -31,8 +39,38 @@ impl BumpFrame { unsafe { &*self.bump.get() } } - #[allow(clippy::mut_from_ref)] - pub(crate) unsafe fn bump_mut(&self) -> &mut Bump { - unsafe { &mut *self.bump.get() } + pub(crate) fn add_attribute_to_drop(&self, attribute: *const Attribute<'static>) { + self.attributes_to_drop_before_reset + .borrow_mut() + .push(attribute); + } + + /// Reset the bump allocator and drop all the attributes and props that were allocated in it. + /// + /// # Safety + /// The caller must insure that no reference to anything allocated in the bump allocator is available after this function is called. + pub(crate) unsafe fn reset(&self) { + let mut attributes = self.attributes_to_drop_before_reset.borrow_mut(); + attributes.drain(..).for_each(|attribute| { + let attribute = unsafe { &*attribute }; + if let AttributeValue::Any(l) = &attribute.value { + _ = l.take(); + } + }); + let mut props = self.props_to_drop_before_reset.borrow_mut(); + props.drain(..).for_each(|prop| { + let prop = unsafe { &*prop }; + _ = prop.props.borrow_mut().take(); + }); + unsafe { + let bump = &mut *self.bump.get(); + bump.reset(); + } + } +} + +impl Drop for BumpFrame { + fn drop(&mut self) { + unsafe { self.reset() } } } diff --git a/packages/core/src/diff.rs b/packages/core/src/diff.rs index 74017db0f..2de862218 100644 --- a/packages/core/src/diff.rs +++ b/packages/core/src/diff.rs @@ -560,7 +560,7 @@ impl<'b> VirtualDom { // If none of the old keys are reused by the new children, then we remove all the remaining old children and // create the new children afresh. if shared_keys.is_empty() { - if old.get(0).is_some() { + if old.first().is_some() { self.remove_nodes(&old[1..]); self.replace(&old[0], new); } else { diff --git a/packages/core/src/events.rs b/packages/core/src/events.rs index 3b8edb05c..784a8c865 100644 --- a/packages/core/src/events.rs +++ b/packages/core/src/events.rs @@ -107,8 +107,6 @@ impl std::fmt::Debug for Event { } } -#[doc(hidden)] - /// The callback type generated by the `rsx!` macro when an `on` field is specified for components. /// /// This makes it possible to pass `move |evt| {}` style closures into components as property fields. diff --git a/packages/core/src/lazynodes.rs b/packages/core/src/lazynodes.rs index 811dbb734..3189086b4 100644 --- a/packages/core/src/lazynodes.rs +++ b/packages/core/src/lazynodes.rs @@ -23,8 +23,37 @@ use crate::{innerlude::VNode, ScopeState}; /// /// /// ```rust, ignore -/// LazyNodes::new(|f| f.element("div", [], [], [] None)) +/// LazyNodes::new(|f| { +/// static TEMPLATE: dioxus::core::Template = dioxus::core::Template { +/// name: "main.rs:5:5:20", // Source location of the template for hot reloading +/// roots: &[ +/// dioxus::core::TemplateNode::Element { +/// tag: dioxus_elements::div::TAG_NAME, +/// namespace: dioxus_elements::div::NAME_SPACE, +/// attrs: &[], +/// children: &[], +/// }, +/// ], +/// node_paths: &[], +/// attr_paths: &[], +/// }; +/// dioxus::core::VNode { +/// parent: None, +/// key: None, +/// template: std::cell::Cell::new(TEMPLATE), +/// root_ids: dioxus::core::exports::bumpalo::collections::Vec::with_capacity_in( +/// 1usize, +/// f.bump(), +/// ) +/// .into(), +/// dynamic_nodes: f.bump().alloc([]), +/// dynamic_attrs: f.bump().alloc([]), +/// }) +/// } /// ``` +/// +/// Find more information about how to construct [`VNode`] at + pub struct LazyNodes<'a, 'b> { #[cfg(not(miri))] inner: SmallBox VNode<'a> + 'b, S16>, @@ -61,7 +90,7 @@ impl<'a, 'b> LazyNodes<'a, 'b> { /// Call the closure with the given factory to produce real [`VNode`]. /// /// ```rust, ignore - /// let f = LazyNodes::new(move |f| f.element("div", [], [], [] None)); + /// let f = LazyNodes::new(/* Closure for creating VNodes */); /// /// let node = f.call(cac); /// ``` diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index ee557af2d..392774767 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -91,9 +91,9 @@ pub mod prelude { consume_context, consume_context_from_scope, current_scope_id, fc_to_builder, has_context, provide_context, provide_context_to_scope, provide_root_context, push_future, remove_future, schedule_update_any, spawn, spawn_forever, suspend, throw, AnyValue, - Component, Element, Event, EventHandler, Fragment, IntoAttributeValue, LazyNodes, - Properties, Runtime, RuntimeGuard, Scope, ScopeId, ScopeState, Scoped, TaskId, Template, - TemplateAttribute, TemplateNode, Throw, VNode, VirtualDom, + Component, Element, Event, EventHandler, Fragment, IntoAttributeValue, IntoDynNode, + LazyNodes, Properties, Runtime, RuntimeGuard, Scope, ScopeId, ScopeState, Scoped, TaskId, + Template, TemplateAttribute, TemplateNode, Throw, VNode, VirtualDom, }; } diff --git a/packages/core/src/mutations.rs b/packages/core/src/mutations.rs index 27976c157..f33590e2e 100644 --- a/packages/core/src/mutations.rs +++ b/packages/core/src/mutations.rs @@ -91,7 +91,7 @@ pub enum Mutation<'a> { id: ElementId, }, - /// Create an placeholder int he DOM that we will use later. + /// Create a placeholder in the DOM that we will use later. /// /// Dioxus currently requires the use of placeholders to maintain a re-entrance point for things like list diffing CreatePlaceholder { diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index c83b1059a..78868d29f 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -707,7 +707,7 @@ impl<'a, 'b> IntoDynNode<'b> for &'a str { impl IntoDynNode<'_> for String { fn into_vnode(self, cx: &ScopeState) -> DynamicNode { DynamicNode::Text(VText { - value: cx.bump().alloc(self), + value: cx.bump().alloc_str(&self), id: Default::default(), }) } @@ -791,6 +791,12 @@ impl<'a> IntoAttributeValue<'a> for &'a str { } } +impl<'a> IntoAttributeValue<'a> for String { + fn into_value(self, cx: &'a Bump) -> AttributeValue<'a> { + AttributeValue::Text(cx.alloc_str(&self)) + } +} + impl<'a> IntoAttributeValue<'a> for f64 { fn into_value(self, _: &'a Bump) -> AttributeValue<'a> { AttributeValue::Float(self) diff --git a/packages/core/src/scope_arena.rs b/packages/core/src/scope_arena.rs index 1ed5b816c..397f5328e 100644 --- a/packages/core/src/scope_arena.rs +++ b/packages/core/src/scope_arena.rs @@ -35,7 +35,7 @@ impl VirtualDom { hook_idx: Default::default(), borrowed_props: Default::default(), - attributes_to_drop: Default::default(), + attributes_to_drop_before_render: Default::default(), })); let context = @@ -54,7 +54,7 @@ impl VirtualDom { let new_nodes = unsafe { let scope = &self.scopes[scope_id.0]; - scope.previous_frame().bump_mut().reset(); + scope.previous_frame().reset(); scope.context().suspended.set(false); diff --git a/packages/core/src/scopes.rs b/packages/core/src/scopes.rs index 996529a0d..f0bbd776c 100644 --- a/packages/core/src/scopes.rs +++ b/packages/core/src/scopes.rs @@ -94,7 +94,7 @@ pub struct ScopeState { pub(crate) hook_idx: Cell, pub(crate) borrowed_props: RefCell>>, - pub(crate) attributes_to_drop: RefCell>>, + pub(crate) attributes_to_drop_before_render: RefCell>>, pub(crate) props: Option>>, } @@ -348,25 +348,36 @@ impl<'src> ScopeState { pub fn render(&'src self, rsx: LazyNodes<'src, '_>) -> Element<'src> { let element = rsx.call(self); - let mut listeners = self.attributes_to_drop.borrow_mut(); + let mut listeners = self.attributes_to_drop_before_render.borrow_mut(); for attr in element.dynamic_attrs { match attr.value { - AttributeValue::Any(_) | AttributeValue::Listener(_) => { + // We need to drop listeners before the next render because they may borrow data from the borrowed props which will be dropped + AttributeValue::Listener(_) => { let unbounded = unsafe { std::mem::transmute(attr as *const Attribute) }; listeners.push(unbounded); } + // We need to drop any values manually to make sure that their drop implementation is called before the next render + AttributeValue::Any(_) => { + let unbounded = unsafe { std::mem::transmute(attr as *const Attribute) }; + self.previous_frame().add_attribute_to_drop(unbounded); + } _ => (), } } let mut props = self.borrowed_props.borrow_mut(); + let mut drop_props = self + .previous_frame() + .props_to_drop_before_reset + .borrow_mut(); for node in element.dynamic_nodes { if let DynamicNode::Component(comp) = node { + let unbounded = unsafe { std::mem::transmute(comp as *const VComponent) }; if !comp.static_props { - let unbounded = unsafe { std::mem::transmute(comp as *const VComponent) }; props.push(unbounded); } + drop_props.push(unbounded); } } diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index d066347e8..d722ebb22 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -60,6 +60,7 @@ devtools = ["wry/devtools"] tray = ["wry/tray"] dox = ["wry/dox"] hot-reload = ["dioxus-hot-reload"] +gnu = [] [package.metadata.docs.rs] default-features = false diff --git a/packages/desktop/build.rs b/packages/desktop/build.rs new file mode 100644 index 000000000..2061732e8 --- /dev/null +++ b/packages/desktop/build.rs @@ -0,0 +1,9 @@ +fn main() { + // WARN about wry support on windows gnu targets. GNU windows targets don't work well in wry currently + if std::env::var("CARGO_CFG_WINDOWS").is_ok() + && std::env::var("CARGO_CFG_TARGET_ENV").unwrap() == "gnu" + && !cfg!(feature = "gnu") + { + println!("cargo:warning=GNU windows targets have some limitations within Wry. Using the MSVC windows toolchain is recommended. If you would like to use continue using GNU, you can read https://github.com/wravery/webview2-rs#cross-compilation and disable this warning by adding the gnu feature to dioxus-desktop in your Cargo.toml") + } +} diff --git a/packages/desktop/src/lib.rs b/packages/desktop/src/lib.rs index f19c35e76..029b4cab6 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -55,7 +55,7 @@ use wry::{application::window::WindowId, webview::WebContext}; /// /// This function will start a multithreaded Tokio runtime as well the WebView event loop. /// -/// ```rust, ignore +/// ```rust, no_run /// use dioxus::prelude::*; /// /// fn main() { @@ -78,11 +78,12 @@ pub fn launch(root: Component) { /// /// You can configure the WebView window with a configuration closure /// -/// ```rust, ignore +/// ```rust, no_run /// use dioxus::prelude::*; +/// use dioxus_desktop::*; /// /// fn main() { -/// dioxus_desktop::launch_cfg(app, |c| c.with_window(|w| w.with_title("My App"))); +/// dioxus_desktop::launch_cfg(app, Config::default().with_window(WindowBuilder::new().with_title("My App"))); /// } /// /// fn app(cx: Scope) -> Element { @@ -101,8 +102,9 @@ pub fn launch_cfg(root: Component, config_builder: Config) { /// /// You can configure the WebView window with a configuration closure /// -/// ```rust, ignore +/// ```rust, no_run /// use dioxus::prelude::*; +/// use dioxus_desktop::Config; /// /// fn main() { /// dioxus_desktop::launch_with_props(app, AppProps { name: "asd" }, Config::default()); @@ -165,6 +167,7 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) // iOS panics if we create a window before the event loop is started let props = Rc::new(Cell::new(Some(props))); let cfg = Rc::new(Cell::new(Some(cfg))); + let mut is_visible_before_start = true; event_loop.run(move |window_event, event_loop, control_flow| { *control_flow = ControlFlow::Wait; @@ -214,6 +217,8 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) // Create a dom let dom = VirtualDom::new_with_props(root, props); + is_visible_before_start = cfg.window.window.visible; + let handler = create_new_window( cfg, event_loop, @@ -327,6 +332,10 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) EventData::Ipc(msg) if msg.method() == "initialize" => { let view = webviews.get_mut(&event.1).unwrap(); send_edits(view.dom.rebuild(), &view.desktop_context.webview); + view.desktop_context + .webview + .window() + .set_visible(is_visible_before_start); } EventData::Ipc(msg) if msg.method() == "browser_open" => { diff --git a/packages/desktop/src/webview.rs b/packages/desktop/src/webview.rs index 47ff8e1f5..7f12562ce 100644 --- a/packages/desktop/src/webview.rs +++ b/packages/desktop/src/webview.rs @@ -13,7 +13,7 @@ pub fn build( proxy: EventLoopProxy, ) -> (WebView, WebContext) { let builder = cfg.window.clone(); - let window = builder.build(event_loop).unwrap(); + let window = builder.with_visible(false).build(event_loop).unwrap(); let file_handler = cfg.file_drop_handler.take(); let custom_head = cfg.custom_head.clone(); let index_file = cfg.custom_index.clone(); diff --git a/packages/dioxus-tui/examples/colorpicker.rs b/packages/dioxus-tui/examples/colorpicker.rs index 00f8ef7e0..01cc38cda 100644 --- a/packages/dioxus-tui/examples/colorpicker.rs +++ b/packages/dioxus-tui/examples/colorpicker.rs @@ -15,21 +15,21 @@ fn app(cx: Scope) -> Element { let mapping: DioxusElementToNodeId = cx.consume_context().unwrap(); // disable templates so that every node has an id and can be queried cx.render(rsx! { - div{ + div { width: "100%", background_color: "hsl({hue}, 70%, {brightness}%)", onmousemove: move |evt| { if let RenderReturn::Ready(node) = cx.root_node() { - if let Some(id) = node.root_ids.borrow().get(0).cloned() { + if let Some(id) = node.root_ids.borrow().first().cloned() { let node = tui_query.get(mapping.get_node_id(id).unwrap()); - let Size{width, height} = node.size().unwrap(); + let Size { width, height } = node.size().unwrap(); let pos = evt.inner().element_coordinates(); - hue.set((pos.x as f32/width as f32)*255.0); - brightness.set((pos.y as f32/height as f32)*100.0); + hue.set((pos.x as f32 / width as f32) * 255.0); + brightness.set((pos.y as f32 / height as f32) * 100.0); } } }, - "hsl({hue}, 70%, {brightness}%)", + "hsl({hue}, 70%, {brightness}%)" } }) } diff --git a/packages/extension/src/lib.rs b/packages/extension/src/lib.rs index abcdaf598..fb9cca8a9 100644 --- a/packages/extension/src/lib.rs +++ b/packages/extension/src/lib.rs @@ -1,17 +1,39 @@ //! This file exports functions into the vscode extension -use dioxus_autofmt::FormattedBlock; +use dioxus_autofmt::{FormattedBlock, IndentOptions, IndentType}; use wasm_bindgen::prelude::*; #[wasm_bindgen] -pub fn format_rsx(raw: String) -> String { - let block = dioxus_autofmt::fmt_block(&raw, 0); +pub fn format_rsx(raw: String, use_tabs: bool, indent_size: usize) -> String { + let block = dioxus_autofmt::fmt_block( + &raw, + 0, + IndentOptions::new( + if use_tabs { + IndentType::Tabs + } else { + IndentType::Spaces + }, + indent_size, + ), + ); block.unwrap() } #[wasm_bindgen] -pub fn format_selection(raw: String) -> String { - let block = dioxus_autofmt::fmt_block(&raw, 0); +pub fn format_selection(raw: String, use_tabs: bool, indent_size: usize) -> String { + let block = dioxus_autofmt::fmt_block( + &raw, + 0, + IndentOptions::new( + if use_tabs { + IndentType::Tabs + } else { + IndentType::Spaces + }, + indent_size, + ), + ); block.unwrap() } @@ -35,8 +57,18 @@ impl FormatBlockInstance { } #[wasm_bindgen] -pub fn format_file(contents: String) -> FormatBlockInstance { - let _edits = dioxus_autofmt::fmt_file(&contents); +pub fn format_file(contents: String, use_tabs: bool, indent_size: usize) -> FormatBlockInstance { + let _edits = dioxus_autofmt::fmt_file( + &contents, + IndentOptions::new( + if use_tabs { + IndentType::Tabs + } else { + IndentType::Spaces + }, + indent_size, + ), + ); let out = dioxus_autofmt::apply_formats(&contents, _edits.clone()); FormatBlockInstance { new: out, _edits } } diff --git a/packages/extension/src/main.ts b/packages/extension/src/main.ts index fc12457ad..76b7261f1 100644 --- a/packages/extension/src/main.ts +++ b/packages/extension/src/main.ts @@ -90,7 +90,13 @@ function fmtDocument(document: vscode.TextDocument) { if (!editor) return; // Need an editor to apply text edits. const contents = editor.document.getText(); - const formatted = dioxus.format_file(contents); + let tabSize: number; + if (typeof editor.options.tabSize === 'number') { + tabSize = editor.options.tabSize; + } else { + tabSize = 4; + } + const formatted = dioxus.format_file(contents, !editor.options.insertSpaces, tabSize); // Replace the entire text document // Yes, this is a bit heavy handed, but the dioxus side doesn't know the line/col scheme that vscode is using diff --git a/packages/fermi/src/callback.rs b/packages/fermi/src/callback.rs index a7ca9155f..bb9f61bf2 100644 --- a/packages/fermi/src/callback.rs +++ b/packages/fermi/src/callback.rs @@ -28,6 +28,7 @@ impl CallbackApi { } } +#[must_use] pub fn use_atom_context(cx: &ScopeState) -> &CallbackApi { todo!() } diff --git a/packages/fermi/src/hooks/atom_ref.rs b/packages/fermi/src/hooks/atom_ref.rs index 74dd9268b..8dbeb2c4d 100644 --- a/packages/fermi/src/hooks/atom_ref.rs +++ b/packages/fermi/src/hooks/atom_ref.rs @@ -13,6 +13,7 @@ use std::{ /// /// /// +#[must_use] pub fn use_atom_ref<'a, T: 'static>( cx: &'a ScopeState, atom: &'static AtomRef, diff --git a/packages/fermi/src/hooks/atom_root.rs b/packages/fermi/src/hooks/atom_root.rs index 24fd70093..468f5f980 100644 --- a/packages/fermi/src/hooks/atom_root.rs +++ b/packages/fermi/src/hooks/atom_root.rs @@ -7,6 +7,6 @@ use dioxus_core::ScopeState; pub fn use_atom_root(cx: &ScopeState) -> &Rc { cx.use_hook(|| match cx.consume_context::>() { Some(root) => root, - None => panic!("No atom root found in context. Did you forget place an AtomRoot component at the top of your app?"), + None => panic!("No atom root found in context. Did you forget to call use_init_atom_root at the top of your app?"), }) } diff --git a/packages/fermi/src/hooks/read.rs b/packages/fermi/src/hooks/read.rs index 57a544e93..c2538c74d 100644 --- a/packages/fermi/src/hooks/read.rs +++ b/packages/fermi/src/hooks/read.rs @@ -2,10 +2,12 @@ use crate::{use_atom_root, AtomId, AtomRoot, Readable}; use dioxus_core::{ScopeId, ScopeState}; use std::rc::Rc; +#[must_use] pub fn use_read(cx: &ScopeState, f: impl Readable) -> &V { use_read_rc(cx, f).as_ref() } +#[must_use] pub fn use_read_rc(cx: &ScopeState, f: impl Readable) -> &Rc { let root = use_atom_root(cx); diff --git a/packages/fermi/src/hooks/set.rs b/packages/fermi/src/hooks/set.rs index 5e5141227..89fe48b0f 100644 --- a/packages/fermi/src/hooks/set.rs +++ b/packages/fermi/src/hooks/set.rs @@ -2,6 +2,7 @@ use crate::{use_atom_root, Writable}; use dioxus_core::ScopeState; use std::rc::Rc; +#[must_use] pub fn use_set(cx: &ScopeState, f: impl Writable) -> &Rc { let root = use_atom_root(cx); cx.use_hook(|| { diff --git a/packages/fermi/src/hooks/state.rs b/packages/fermi/src/hooks/state.rs index 071b18059..7d473588a 100644 --- a/packages/fermi/src/hooks/state.rs +++ b/packages/fermi/src/hooks/state.rs @@ -30,6 +30,7 @@ use std::{ /// )) /// } /// ``` +#[must_use] pub fn use_atom_state(cx: &ScopeState, f: impl Writable) -> &AtomState { let root = crate::use_atom_root(cx); @@ -85,7 +86,9 @@ impl AtomState { /// ``` #[must_use] pub fn current(&self) -> Rc { - self.value.as_ref().unwrap().clone() + let atoms = self.root.atoms.borrow(); + let slot = atoms.get(&self.id).unwrap(); + slot.value.clone().downcast().unwrap() } /// Get the `setter` function directly without the `AtomState` wrapper. diff --git a/packages/fermi/src/lib.rs b/packages/fermi/src/lib.rs index 52bf4cd09..dc5ccf010 100644 --- a/packages/fermi/src/lib.rs +++ b/packages/fermi/src/lib.rs @@ -22,8 +22,6 @@ mod atoms { pub use atom::*; pub use atomfamily::*; pub use atomref::*; - pub use selector::*; - pub use selectorfamily::*; } pub mod hooks { diff --git a/packages/fullstack/Cargo.toml b/packages/fullstack/Cargo.toml index 9051b3994..2867c1c72 100644 --- a/packages/fullstack/Cargo.toml +++ b/packages/fullstack/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["ui", "gui", "react", "ssr", "fullstack"] [dependencies] # server functions -server_fn = { version = "0.4.6", default-features = false } +server_fn = { version = "0.5.2", default-features = false } dioxus_server_macro = { workspace = true } # warp diff --git a/packages/fullstack/examples/axum-hello-world/src/main.rs b/packages/fullstack/examples/axum-hello-world/src/main.rs index 8f1d3c8c6..64e5b44a0 100644 --- a/packages/fullstack/examples/axum-hello-world/src/main.rs +++ b/packages/fullstack/examples/axum-hello-world/src/main.rs @@ -24,6 +24,7 @@ fn app(cx: Scope) -> Element { let mut count = use_state(cx, || 0); let text = use_state(cx, || "...".to_string()); + let eval = use_eval(cx); cx.render(rsx! { div { diff --git a/packages/fullstack/src/adapters/axum_adapter.rs b/packages/fullstack/src/adapters/axum_adapter.rs index baa10e3a5..9f9f3b11e 100644 --- a/packages/fullstack/src/adapters/axum_adapter.rs +++ b/packages/fullstack/src/adapters/axum_adapter.rs @@ -372,15 +372,65 @@ fn apply_request_parts_to_response( } } -/// SSR renderer handler for Axum -pub async fn render_handler( - State((cfg, ssr_state)): State<(ServeConfig

, SSRState)>, +/// SSR renderer handler for Axum with added context injection. +/// +/// # Example +/// ```rust,no_run +/// #![allow(non_snake_case)] +/// use std::sync::{Arc, Mutex}; +/// +/// use axum::routing::get; +/// use dioxus::prelude::*; +/// use dioxus_fullstack::{axum_adapter::render_handler_with_context, prelude::*}; +/// +/// fn app(cx: Scope) -> Element { +/// render! { +/// "hello!" +/// } +/// } +/// +/// #[tokio::main] +/// async fn main() { +/// let cfg = ServeConfigBuilder::new(app, ()) +/// .assets_path("dist") +/// .build(); +/// let ssr_state = SSRState::new(&cfg); +/// +/// // This could be any state you want to be accessible from your server +/// // functions using `[DioxusServerContext::get]`. +/// let state = Arc::new(Mutex::new("state".to_string())); +/// +/// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); +/// axum::Server::bind(&addr) +/// .serve( +/// axum::Router::new() +/// // Register server functions, etc. +/// // Note you probably want to use `register_server_fns_with_handler` +/// // to inject the context into server functions running outside +/// // of an SSR render context. +/// .fallback(get(render_handler_with_context).with_state(( +/// move |ctx| ctx.insert(state.clone()).unwrap(), +/// cfg, +/// ssr_state, +/// ))) +/// .into_make_service(), +/// ) +/// .await +/// .unwrap(); +/// } +/// ``` +pub async fn render_handler_with_context< + P: Clone + serde::Serialize + Send + Sync + 'static, + F: FnMut(&mut DioxusServerContext), +>( + State((mut inject_context, cfg, ssr_state)): State<(F, ServeConfig

, SSRState)>, request: Request, ) -> impl IntoResponse { let (parts, _) = request.into_parts(); let url = parts.uri.path_and_query().unwrap().to_string(); let parts: Arc> = Arc::new(RwLock::new(parts.into())); - let server_context = DioxusServerContext::new(parts.clone()); + let mut server_context = DioxusServerContext::new(parts.clone()); + inject_context(&mut server_context); match ssr_state.render(url, &cfg, &server_context).await { Ok(rendered) => { @@ -398,6 +448,14 @@ pub async fn render_handler } } +/// SSR renderer handler for Axum +pub async fn render_handler( + State((cfg, ssr_state)): State<(ServeConfig

, SSRState)>, + request: Request, +) -> impl IntoResponse { + render_handler_with_context(State((|_: &mut _| (), cfg, ssr_state)), request).await +} + fn report_err(e: E) -> Response { Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) diff --git a/packages/fullstack/src/adapters/mod.rs b/packages/fullstack/src/adapters/mod.rs index 98d190919..d368b9c73 100644 --- a/packages/fullstack/src/adapters/mod.rs +++ b/packages/fullstack/src/adapters/mod.rs @@ -89,26 +89,26 @@ impl Service for ServerFnHandler { let parts = Arc::new(RwLock::new(parts)); // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime - let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); let pool = get_local_pool(); - pool.spawn_pinned({ - let function = function.clone(); - let mut server_context = server_context.clone(); - server_context.parts = parts; - move || async move { - let data = match function.encoding() { - Encoding::Url | Encoding::Cbor => &body, - Encoding::GetJSON | Encoding::GetCBOR => &query, - }; - let server_function_future = function.call((), data); - let server_function_future = - ProvideServerContext::new(server_function_future, server_context.clone()); - let resp = server_function_future.await; - - resp_tx.send(resp).unwrap(); - } - }); - let result = resp_rx.await.unwrap(); + let result = pool + .spawn_pinned({ + let function = function.clone(); + let mut server_context = server_context.clone(); + server_context.parts = parts; + move || async move { + let data = match function.encoding() { + Encoding::Url | Encoding::Cbor => &body, + Encoding::GetJSON | Encoding::GetCBOR => &query, + }; + let server_function_future = function.call((), data); + let server_function_future = ProvideServerContext::new( + server_function_future, + server_context.clone(), + ); + server_function_future.await + } + }) + .await?; let mut res = http::Response::builder(); // Set the headers from the server context diff --git a/packages/fullstack/src/hooks/server_future.rs b/packages/fullstack/src/hooks/server_future.rs index e4d89480e..f8eaef963 100644 --- a/packages/fullstack/src/hooks/server_future.rs +++ b/packages/fullstack/src/hooks/server_future.rs @@ -22,6 +22,7 @@ use std::sync::Arc; /// will be allowed to continue /// /// - dependencies: a tuple of references to values that are PartialEq + Clone +#[must_use = "Consider using `cx.spawn` to run a future without reading its value"] pub fn use_server_future( cx: &ScopeState, dependencies: D, diff --git a/packages/fullstack/src/launch.rs b/packages/fullstack/src/launch.rs index c982a1c90..bd145f363 100644 --- a/packages/fullstack/src/launch.rs +++ b/packages/fullstack/src/launch.rs @@ -121,8 +121,15 @@ impl BoxedService; } @@ -17,7 +19,9 @@ where } } +/// A service is a function that takes a request and returns an async response pub trait Service { + /// Run the service and produce a future that resolves to a response fn run( &mut self, req: http::Request, @@ -55,6 +59,7 @@ where } } +/// A boxed service is a type-erased service that can be used without knowing the underlying type pub struct BoxedService(pub Box); impl tower::Service> for BoxedService { diff --git a/packages/fullstack/src/lib.rs b/packages/fullstack/src/lib.rs index 96cd4b17b..8b166757f 100644 --- a/packages/fullstack/src/lib.rs +++ b/packages/fullstack/src/lib.rs @@ -41,6 +41,8 @@ pub mod prelude { #[cfg(not(feature = "ssr"))] pub use crate::html_storage::deserialize::get_root_props_from_document; pub use crate::launch::LaunchBuilder; + #[cfg(feature = "ssr")] + pub use crate::layer::{Layer, Service}; #[cfg(all(feature = "ssr", feature = "router"))] pub use crate::render::pre_cache_static_routes_with_props; #[cfg(feature = "ssr")] @@ -65,3 +67,10 @@ pub mod prelude { pub use hooks::{server_cached::server_cached, server_future::use_server_future}; } + +// Warn users about overlapping features +#[cfg(all(feature = "ssr", feature = "web"))] +compile_error!("The `ssr` feature (enabled by `warp`, `axum`, or `salvo`) and `web` feature are overlapping. Please choose one or the other."); + +#[cfg(all(feature = "ssr", feature = "desktop"))] +compile_error!("The `ssr` feature (enabled by `warp`, `axum`, or `salvo`) and `desktop` feature are overlapping. Please choose one or the other."); diff --git a/packages/fullstack/src/render.rs b/packages/fullstack/src/render.rs index cda41f82c..886a5bd99 100644 --- a/packages/fullstack/src/render.rs +++ b/packages/fullstack/src/render.rs @@ -45,6 +45,8 @@ impl SsrRendererPool { .expect("couldn't spawn runtime") .block_on(async move { let mut vdom = VirtualDom::new_with_props(component, props); + // Make sure the evaluator is initialized + dioxus_ssr::eval::init_eval(vdom.base_scope()); let mut to = WriteBuffer { buffer: Vec::new() }; // before polling the future, we need to set the context let prev_context = diff --git a/packages/fullstack/src/router.rs b/packages/fullstack/src/router.rs index b556aa000..b8d93f0e0 100644 --- a/packages/fullstack/src/router.rs +++ b/packages/fullstack/src/router.rs @@ -53,7 +53,7 @@ fn default_external_navigation_handler() -> fn(Scope) -> Element { dioxus_router::prelude::FailureExternalNavigation } -/// The configeration for the router +/// The configuration for the router #[derive(Props, serde::Serialize, serde::Deserialize)] pub struct FullstackRouterConfig where diff --git a/packages/fullstack/src/server_fn.rs b/packages/fullstack/src/server_fn.rs index 539a0d70a..fd0639372 100644 --- a/packages/fullstack/src/server_fn.rs +++ b/packages/fullstack/src/server_fn.rs @@ -125,14 +125,6 @@ impl server_fn::ServerFunctionRegistry<()> for DioxusServerFnRegistry { } } - fn register( - url: &'static str, - server_function: ServerFunction, - encoding: server_fn::Encoding, - ) -> Result<(), Self::Error> { - Self::register_explicit("", url, server_function, encoding) - } - /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. fn get(url: &str) -> Option> { REGISTERED_SERVER_FUNCTIONS diff --git a/packages/generational-box/Cargo.toml b/packages/generational-box/Cargo.toml index 6c5a5a841..64d36bd13 100644 --- a/packages/generational-box/Cargo.toml +++ b/packages/generational-box/Cargo.toml @@ -1,9 +1,12 @@ [package] name = "generational-box" authors = ["Evan Almloff"] -version = "0.0.0" +version = "0.4.3" edition = "2018" - +description = "A box backed by a generational runtime" +license = "MIT OR Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +keywords = ["generational", "box", "memory", "allocator"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -15,3 +18,5 @@ rand = "0.8.5" [features] default = ["check_generation"] check_generation = [] +debug_borrows = [] +debug_ownership = [] diff --git a/packages/generational-box/README.md b/packages/generational-box/README.md index 63c539e08..95aab8402 100644 --- a/packages/generational-box/README.md +++ b/packages/generational-box/README.md @@ -11,6 +11,8 @@ Three main types manage state in Generational Box: Example: ```rust +use generational_box::Store; + // Create a store for this thread let store = Store::default(); @@ -31,4 +33,4 @@ let store = Store::default(); ## How it works -Internally \ No newline at end of file +Internally, `generational-box` creates an arena of generational RefCell's that are recyled when the owner is dropped. You can think of the cells as something like `&'static RefCell>` with a generational check to make recyling a cell easier to debug. Then GenerationalBox's are `Copy` because the `&'static` pointer is `Copy` diff --git a/packages/generational-box/src/lib.rs b/packages/generational-box/src/lib.rs index 60bd41419..f56904ede 100644 --- a/packages/generational-box/src/lib.rs +++ b/packages/generational-box/src/lib.rs @@ -2,9 +2,12 @@ #![warn(missing_docs)] use std::{ + any::Any, cell::{Cell, Ref, RefCell, RefMut}, - fmt::Debug, + error::Error, + fmt::{Debug, Display}, marker::PhantomData, + ops::{Deref, DerefMut}, rc::Rc, }; @@ -29,12 +32,12 @@ fn reused() { let first_ptr; { let owner = store.owner(); - first_ptr = owner.insert(1).raw.data.as_ptr(); + first_ptr = owner.insert(1).raw.0.data.as_ptr(); drop(owner); } { let owner = store.owner(); - let second_ptr = owner.insert(1234).raw.data.as_ptr(); + let second_ptr = owner.insert(1234).raw.0.data.as_ptr(); assert_eq!(first_ptr, second_ptr); drop(owner); } @@ -53,7 +56,10 @@ fn leaking_is_ok() { // don't drop the owner std::mem::forget(owner); } - assert_eq!(key.try_read().as_deref(), Some(&"hello world".to_string())); + assert_eq!( + key.try_read().as_deref().unwrap(), + &"hello world".to_string() + ); } #[test] @@ -68,7 +74,7 @@ fn drops() { key = owner.insert(data); // drop the owner } - assert!(key.try_read().is_none()); + assert!(key.try_read().is_err()); } #[test] @@ -129,7 +135,7 @@ fn fuzz() { println!("{:?}", path); for key in valid_keys.iter() { let value = key.read(); - println!("{:?}", value); + println!("{:?}", &*value); assert!(value.starts_with("hello world")); } #[cfg(any(debug_assertions, feature = "check_generation"))] @@ -153,6 +159,8 @@ pub struct GenerationalBox { raw: MemoryLocation, #[cfg(any(debug_assertions, feature = "check_generation"))] generation: u32, + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at: &'static std::panic::Location<'static>, _marker: PhantomData, } @@ -161,7 +169,7 @@ impl Debug for GenerationalBox { #[cfg(any(debug_assertions, feature = "check_generation"))] f.write_fmt(format_args!( "{:?}@{:?}", - self.raw.data.as_ptr(), + self.raw.0.data.as_ptr(), self.generation ))?; #[cfg(not(any(debug_assertions, feature = "check_generation")))] @@ -175,7 +183,7 @@ impl GenerationalBox { fn validate(&self) -> bool { #[cfg(any(debug_assertions, feature = "check_generation"))] { - self.raw.generation.get() == self.generation + self.raw.0.generation.get() == self.generation } #[cfg(not(any(debug_assertions, feature = "check_generation")))] { @@ -184,43 +192,51 @@ impl GenerationalBox { } /// Try to read the value. Returns None if the value is no longer valid. - pub fn try_read(&self) -> Option> { - self.validate() - .then(|| { - Ref::filter_map(self.raw.data.borrow(), |any| { - any.as_ref()?.downcast_ref::() - }) - .ok() - }) - .flatten() + #[track_caller] + pub fn try_read(&self) -> Result, BorrowError> { + if !self.validate() { + return Err(BorrowError::Dropped(ValueDroppedError { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + created_at: self.created_at, + })); + } + self.raw.try_borrow( + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + self.created_at, + ) } /// Read the value. Panics if the value is no longer valid. - pub fn read(&self) -> Ref<'_, T> { + #[track_caller] + pub fn read(&self) -> GenerationalRef { self.try_read().unwrap() } /// Try to write the value. Returns None if the value is no longer valid. - pub fn try_write(&self) -> Option> { - self.validate() - .then(|| { - RefMut::filter_map(self.raw.data.borrow_mut(), |any| { - any.as_mut()?.downcast_mut::() - }) - .ok() - }) - .flatten() + #[track_caller] + pub fn try_write(&self) -> Result, BorrowMutError> { + if !self.validate() { + return Err(BorrowMutError::Dropped(ValueDroppedError { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + created_at: self.created_at, + })); + } + self.raw.try_borrow_mut( + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + self.created_at, + ) } /// Write the value. Panics if the value is no longer valid. - pub fn write(&self) -> RefMut<'_, T> { + #[track_caller] + pub fn write(&self) -> GenerationalRefMut { self.try_write().unwrap() } /// Set the value. Panics if the value is no longer valid. pub fn set(&self, value: T) { self.validate().then(|| { - *self.raw.data.borrow_mut() = Some(Box::new(value)); + *self.raw.0.data.borrow_mut() = Some(Box::new(value)); }); } @@ -228,7 +244,8 @@ impl GenerationalBox { pub fn ptr_eq(&self, other: &Self) -> bool { #[cfg(any(debug_assertions, feature = "check_generation"))] { - self.raw.data.as_ptr() == other.raw.data.as_ptr() && self.generation == other.generation + self.raw.0.data.as_ptr() == other.raw.0.data.as_ptr() + && self.generation == other.generation } #[cfg(not(any(debug_assertions, feature = "check_generation")))] { @@ -246,26 +263,37 @@ impl Clone for GenerationalBox { } #[derive(Clone, Copy)] -struct MemoryLocation { - data: &'static RefCell>>, +struct MemoryLocation(&'static MemoryLocationInner); + +struct MemoryLocationInner { + data: RefCell>>, #[cfg(any(debug_assertions, feature = "check_generation"))] - generation: &'static Cell, + generation: Cell, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_at: RefCell>>, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_mut_at: Cell>>, } impl MemoryLocation { #[allow(unused)] fn drop(&self) { - let old = self.data.borrow_mut().take(); + let old = self.0.data.borrow_mut().take(); #[cfg(any(debug_assertions, feature = "check_generation"))] if old.is_some() { drop(old); - let new_generation = self.generation.get() + 1; - self.generation.set(new_generation); + let new_generation = self.0.generation.get() + 1; + self.0.generation.set(new_generation); } } - fn replace(&mut self, value: T) -> GenerationalBox { - let mut inner_mut = self.data.borrow_mut(); + fn replace_with_caller( + &mut self, + value: T, + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + caller: &'static std::panic::Location<'static>, + ) -> GenerationalBox { + let mut inner_mut = self.0.data.borrow_mut(); let raw = Box::new(value); let old = inner_mut.replace(raw); @@ -273,10 +301,315 @@ impl MemoryLocation { GenerationalBox { raw: *self, #[cfg(any(debug_assertions, feature = "check_generation"))] - generation: self.generation.get(), + generation: self.0.generation.get(), + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at: caller, _marker: PhantomData, } } + + #[track_caller] + fn try_borrow( + &self, + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at: &'static std::panic::Location<'static>, + ) -> Result, BorrowError> { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + self.0 + .borrowed_at + .borrow_mut() + .push(std::panic::Location::caller()); + match self.0.data.try_borrow() { + Ok(borrow) => match Ref::filter_map(borrow, |any| any.as_ref()?.downcast_ref::()) { + Ok(reference) => Ok(GenerationalRef { + inner: reference, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: GenerationalRefBorrowInfo { + borrowed_at: std::panic::Location::caller(), + borrowed_from: self.0, + }, + }), + Err(_) => Err(BorrowError::Dropped(ValueDroppedError { + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at, + })), + }, + Err(_) => Err(BorrowError::AlreadyBorrowedMut(AlreadyBorrowedMutError { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_mut_at: self.0.borrowed_mut_at.get().unwrap(), + })), + } + } + + #[track_caller] + fn try_borrow_mut( + &self, + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at: &'static std::panic::Location<'static>, + ) -> Result, BorrowMutError> { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + { + self.0 + .borrowed_mut_at + .set(Some(std::panic::Location::caller())); + } + match self.0.data.try_borrow_mut() { + Ok(borrow_mut) => { + match RefMut::filter_map(borrow_mut, |any| any.as_mut()?.downcast_mut::()) { + Ok(reference) => Ok(GenerationalRefMut { + inner: reference, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: GenerationalRefMutBorrowInfo { + borrowed_from: self.0, + }, + }), + Err(_) => Err(BorrowMutError::Dropped(ValueDroppedError { + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at, + })), + } + } + Err(_) => Err(BorrowMutError::AlreadyBorrowed(AlreadyBorrowedError { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_at: self.0.borrowed_at.borrow().clone(), + })), + } + } +} + +#[derive(Debug, Clone)] +/// An error that can occur when trying to borrow a value. +pub enum BorrowError { + /// The value was dropped. + Dropped(ValueDroppedError), + /// The value was already borrowed mutably. + AlreadyBorrowedMut(AlreadyBorrowedMutError), +} + +impl Display for BorrowError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BorrowError::Dropped(error) => Display::fmt(error, f), + BorrowError::AlreadyBorrowedMut(error) => Display::fmt(error, f), + } + } +} + +impl Error for BorrowError {} + +#[derive(Debug, Clone)] +/// An error that can occur when trying to borrow a value mutably. +pub enum BorrowMutError { + /// The value was dropped. + Dropped(ValueDroppedError), + /// The value was already borrowed. + AlreadyBorrowed(AlreadyBorrowedError), + /// The value was already borrowed mutably. + AlreadyBorrowedMut(AlreadyBorrowedMutError), +} + +impl Display for BorrowMutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BorrowMutError::Dropped(error) => Display::fmt(error, f), + BorrowMutError::AlreadyBorrowedMut(error) => Display::fmt(error, f), + BorrowMutError::AlreadyBorrowed(error) => Display::fmt(error, f), + } + } +} + +impl Error for BorrowMutError {} + +/// An error that can occur when trying to use a value that has been dropped. +#[derive(Debug, Copy, Clone)] +pub struct ValueDroppedError { + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at: &'static std::panic::Location<'static>, +} + +impl Display for ValueDroppedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Failed to borrow because the value was dropped.")?; + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + f.write_fmt(format_args!("created_at: {}", self.created_at))?; + Ok(()) + } +} + +impl std::error::Error for ValueDroppedError {} + +/// An error that can occur when trying to borrow a value that has already been borrowed mutably. +#[derive(Debug, Copy, Clone)] +pub struct AlreadyBorrowedMutError { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_mut_at: &'static std::panic::Location<'static>, +} + +impl Display for AlreadyBorrowedMutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Failed to borrow because the value was already borrowed mutably.")?; + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + f.write_fmt(format_args!("borrowed_mut_at: {}", self.borrowed_mut_at))?; + Ok(()) + } +} + +impl std::error::Error for AlreadyBorrowedMutError {} + +/// An error that can occur when trying to borrow a value mutably that has already been borrowed immutably. +#[derive(Debug, Clone)] +pub struct AlreadyBorrowedError { + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_at: Vec<&'static std::panic::Location<'static>>, +} + +impl Display for AlreadyBorrowedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Failed to borrow mutably because the value was already borrowed immutably.")?; + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + f.write_str("borrowed_at:")?; + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + for location in self.borrowed_at.iter() { + f.write_fmt(format_args!("\t{}", location))?; + } + Ok(()) + } +} + +impl std::error::Error for AlreadyBorrowedError {} + +/// A reference to a value in a generational box. +pub struct GenerationalRef { + inner: Ref<'static, T>, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: GenerationalRefBorrowInfo, +} + +impl GenerationalRef { + /// Map one ref type to another. + pub fn map(orig: GenerationalRef, f: F) -> GenerationalRef + where + F: FnOnce(&T) -> &U, + { + GenerationalRef { + inner: Ref::map(orig.inner, f), + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: GenerationalRefBorrowInfo { + borrowed_at: orig.borrow.borrowed_at, + borrowed_from: orig.borrow.borrowed_from, + }, + } + } + + /// Filter one ref type to another. + pub fn filter_map(orig: GenerationalRef, f: F) -> Option> + where + F: FnOnce(&T) -> Option<&U>, + { + let Self { + inner, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow, + } = orig; + Ref::filter_map(inner, f).ok().map(|inner| GenerationalRef { + inner, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: GenerationalRefBorrowInfo { + borrowed_at: borrow.borrowed_at, + borrowed_from: borrow.borrowed_from, + }, + }) + } +} + +impl Deref for GenerationalRef { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.inner.deref() + } +} + +#[cfg(any(debug_assertions, feature = "debug_borrows"))] +struct GenerationalRefBorrowInfo { + borrowed_at: &'static std::panic::Location<'static>, + borrowed_from: &'static MemoryLocationInner, +} + +#[cfg(any(debug_assertions, feature = "debug_borrows"))] +impl Drop for GenerationalRefBorrowInfo { + fn drop(&mut self) { + self.borrowed_from + .borrowed_at + .borrow_mut() + .retain(|location| std::ptr::eq(*location, self.borrowed_at as *const _)); + } +} + +/// A mutable reference to a value in a generational box. +pub struct GenerationalRefMut { + inner: RefMut<'static, T>, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: GenerationalRefMutBorrowInfo, +} + +impl GenerationalRefMut { + /// Map one ref type to another. + pub fn map(orig: GenerationalRefMut, f: F) -> GenerationalRefMut + where + F: FnOnce(&mut T) -> &mut U, + { + GenerationalRefMut { + inner: RefMut::map(orig.inner, f), + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow: orig.borrow, + } + } + + /// Filter one ref type to another. + pub fn filter_map(orig: GenerationalRefMut, f: F) -> Option> + where + F: FnOnce(&mut T) -> Option<&mut U>, + { + let Self { + inner, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow, + } = orig; + RefMut::filter_map(inner, f) + .ok() + .map(|inner| GenerationalRefMut { + inner, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrow, + }) + } +} + +impl Deref for GenerationalRefMut { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.inner.deref() + } +} + +impl DerefMut for GenerationalRefMut { + fn deref_mut(&mut self) -> &mut Self::Target { + self.inner.deref_mut() + } +} + +#[cfg(any(debug_assertions, feature = "debug_borrows"))] +struct GenerationalRefMutBorrowInfo { + borrowed_from: &'static MemoryLocationInner, +} + +#[cfg(any(debug_assertions, feature = "debug_borrows"))] +impl Drop for GenerationalRefMutBorrowInfo { + fn drop(&mut self) { + self.borrowed_from.borrowed_mut_at.take(); + } } /// Handles recycling generational boxes that have been dropped. Your application should have one store or one store per thread. @@ -305,12 +638,16 @@ impl Store { if let Some(location) = self.recycled.borrow_mut().pop() { location } else { - let data: &'static RefCell<_> = self.bump.alloc(RefCell::new(None)); - MemoryLocation { - data, + let data: &'static MemoryLocationInner = self.bump.alloc(MemoryLocationInner { + data: RefCell::new(None), #[cfg(any(debug_assertions, feature = "check_generation"))] - generation: self.bump.alloc(Cell::new(0)), - } + generation: Cell::new(0), + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_at: Default::default(), + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + borrowed_mut_at: Default::default(), + }); + MemoryLocation(data) } } @@ -331,9 +668,31 @@ pub struct Owner { impl Owner { /// Insert a value into the store. The value will be dropped when the owner is dropped. + #[track_caller] pub fn insert(&self, value: T) -> GenerationalBox { let mut location = self.store.claim(); - let key = location.replace(value); + let key = location.replace_with_caller( + value, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + std::panic::Location::caller(), + ); + self.owned.borrow_mut().push(location); + key + } + + /// Insert a value into the store with a specific location blamed for creating the value. The value will be dropped when the owner is dropped. + pub fn insert_with_caller( + &self, + value: T, + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + caller: &'static std::panic::Location<'static>, + ) -> GenerationalBox { + let mut location = self.store.claim(); + let key = location.replace_with_caller( + value, + #[cfg(any(debug_assertions, feature = "debug_borrows"))] + caller, + ); self.owned.borrow_mut().push(location); key } @@ -344,7 +703,9 @@ impl Owner { GenerationalBox { raw: location, #[cfg(any(debug_assertions, feature = "check_generation"))] - generation: location.generation.get(), + generation: location.0.generation.get(), + #[cfg(any(debug_assertions, feature = "debug_ownership"))] + created_at: std::panic::Location::caller(), _marker: PhantomData, } } diff --git a/packages/hooks/src/computed.rs b/packages/hooks/src/computed.rs index 3369d6832..dbea75d73 100644 --- a/packages/hooks/src/computed.rs +++ b/packages/hooks/src/computed.rs @@ -37,6 +37,7 @@ use std::{ /// } /// } /// ``` +#[must_use] pub fn use_tracked_state(cx: &ScopeState, init: impl FnOnce() -> T) -> &Tracked { cx.use_hook(|| { let init = init(); @@ -160,6 +161,7 @@ impl Drop for Tracker { } } +#[must_use = "Consider using the `use_effect` hook to rerun an effect whenever the tracked state changes if you don't need the result of the computation"] pub fn use_selector( cx: &ScopeState, tracked: &Tracked, diff --git a/packages/hooks/src/lib.rs b/packages/hooks/src/lib.rs index a5b92a036..8178d4d3d 100644 --- a/packages/hooks/src/lib.rs +++ b/packages/hooks/src/lib.rs @@ -60,6 +60,9 @@ pub mod computed; mod use_on_destroy; pub use use_on_destroy::*; +mod use_const; +pub use use_const::*; + mod use_context; pub use use_context::*; diff --git a/packages/hooks/src/use_callback.rs b/packages/hooks/src/use_callback.rs index 3f223b581..52e592f6b 100644 --- a/packages/hooks/src/use_callback.rs +++ b/packages/hooks/src/use_callback.rs @@ -24,6 +24,7 @@ macro_rules! use_callback { ) }; } + pub fn use_callback(cx: &ScopeState, make: impl FnOnce() -> R) -> impl FnMut(T) + '_ where R: FnMut(T) -> F + 'static, diff --git a/packages/hooks/src/use_const.rs b/packages/hooks/src/use_const.rs new file mode 100644 index 000000000..47ddb1ed2 --- /dev/null +++ b/packages/hooks/src/use_const.rs @@ -0,0 +1,76 @@ +use std::rc::Rc; + +use dioxus_core::prelude::*; + +/// Store constant state between component renders. +/// +/// UseConst allows you to store state that is initialized once and then remains constant across renders. +/// You can only get an immutable reference after initalization. +/// This can be useful for values that don't need to update reactively, thus can be memoized easily +/// +/// ```rust, ignore +/// struct ComplexData(i32); +/// +/// fn Component(cx: Scope) -> Element { +/// let id = use_const(cx, || ComplexData(100)); +/// +/// cx.render(rsx! { +/// div { "{id.0}" } +/// }) +/// } +/// ``` +#[must_use] +pub fn use_const( + cx: &ScopeState, + initial_state_fn: impl FnOnce() -> T, +) -> &UseConst { + cx.use_hook(|| UseConst { + value: Rc::new(initial_state_fn()), + }) +} + +#[derive(Clone)] +pub struct UseConst { + value: Rc, +} + +impl PartialEq for UseConst { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.value, &other.value) + } +} + +impl core::fmt::Display for UseConst { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } +} + +impl UseConst { + pub fn get_rc(&self) -> &Rc { + &self.value + } +} + +impl std::ops::Deref for UseConst { + type Target = T; + + fn deref(&self) -> &Self::Target { + self.value.as_ref() + } +} + +#[test] +fn use_const_makes_sense() { + #[allow(unused)] + + fn app(cx: Scope) -> Element { + let const_val = use_const(cx, || vec![0, 1, 2, 3]); + + assert!(const_val[0] == 0); + + // const_val.remove(0); // Cannot Compile, cannot get mutable reference now + + None + } +} diff --git a/packages/hooks/src/use_context.rs b/packages/hooks/src/use_context.rs index fdf363252..0d94492f6 100644 --- a/packages/hooks/src/use_context.rs +++ b/packages/hooks/src/use_context.rs @@ -3,6 +3,7 @@ use dioxus_core::ScopeState; /// Consume some context in the tree, providing a sharable handle to the value /// /// Does not regenerate the value if the value is changed at the parent. +#[must_use] pub fn use_context(cx: &ScopeState) -> Option<&T> { cx.use_hook(|| cx.consume_context::()).as_ref() } diff --git a/packages/hooks/src/use_coroutine.rs b/packages/hooks/src/use_coroutine.rs index e5563d793..f2577b90c 100644 --- a/packages/hooks/src/use_coroutine.rs +++ b/packages/hooks/src/use_coroutine.rs @@ -79,6 +79,7 @@ where /// Get a handle to a coroutine higher in the tree /// /// See the docs for [`use_coroutine`] for more details. +#[must_use] pub fn use_coroutine_handle(cx: &ScopeState) -> Option<&Coroutine> { cx.use_hook(|| cx.consume_context::>()) .as_ref() diff --git a/packages/hooks/src/use_effect.rs b/packages/hooks/src/use_effect.rs index e72afe27f..6fb2fcf9e 100644 --- a/packages/hooks/src/use_effect.rs +++ b/packages/hooks/src/use_effect.rs @@ -1,5 +1,10 @@ use dioxus_core::{ScopeState, TaskId}; -use std::{any::Any, cell::Cell, future::Future}; +use std::{ + any::Any, + cell::{Cell, RefCell}, + future::Future, + rc::Rc, +}; use crate::UseFutureDep; @@ -14,7 +19,7 @@ use crate::UseFutureDep; /// ## Arguments /// /// - `dependencies`: a tuple of references to values that are `PartialEq` + `Clone`. -/// - `future`: a closure that takes the `dependencies` as arguments and returns a `'static` future. +/// - `future`: a closure that takes the `dependencies` as arguments and returns a `'static` future. That future may return nothing or a closure that will be executed when the dependencies change to clean up the effect. /// /// ## Examples /// @@ -33,6 +38,16 @@ use crate::UseFutureDep; /// } /// }); /// +/// // Only fetch the user data when the id changes. +/// use_effect(cx, (id,), |(id,)| { +/// to_owned![name]; +/// async move { +/// let user = fetch_user(id).await; +/// name.set(user.name); +/// move || println!("Cleaning up from {}", id) +/// } +/// }); +/// /// let name = name.get().clone().unwrap_or("Loading...".to_string()); /// /// render!( @@ -45,34 +60,80 @@ use crate::UseFutureDep; /// render!(Profile { id: 0 }) /// } /// ``` -pub fn use_effect(cx: &ScopeState, dependencies: D, future: impl FnOnce(D::Out) -> F) +pub fn use_effect(cx: &ScopeState, dependencies: D, future: impl FnOnce(D::Out) -> R) where - T: 'static, - F: Future + 'static, D: UseFutureDep, + R: UseEffectReturn, { struct UseEffect { needs_regen: bool, task: Cell>, dependencies: Vec>, + cleanup: UseEffectCleanup, + } + + impl Drop for UseEffect { + fn drop(&mut self) { + if let Some(cleanup) = self.cleanup.borrow_mut().take() { + cleanup(); + } + } } let state = cx.use_hook(move || UseEffect { needs_regen: true, task: Cell::new(None), dependencies: Vec::new(), + cleanup: Rc::new(RefCell::new(None)), }); if dependencies.clone().apply(&mut state.dependencies) || state.needs_regen { + // Call the cleanup function if it exists + if let Some(cleanup) = state.cleanup.borrow_mut().take() { + cleanup(); + } + // We don't need regen anymore state.needs_regen = false; // Create the new future - let fut = future(dependencies.out()); + let return_value = future(dependencies.out()); - state.task.set(Some(cx.push_future(async move { - fut.await; - }))); + if let Some(task) = return_value.apply(state.cleanup.clone(), cx) { + state.task.set(Some(task)); + } + } +} + +type UseEffectCleanup = Rc>>>; + +/// Something that can be returned from a `use_effect` hook. +pub trait UseEffectReturn { + fn apply(self, oncleanup: UseEffectCleanup, cx: &ScopeState) -> Option; +} + +impl UseEffectReturn<()> for T +where + T: Future + 'static, +{ + fn apply(self, _: UseEffectCleanup, cx: &ScopeState) -> Option { + Some(cx.push_future(self)) + } +} + +#[doc(hidden)] +pub struct CleanupFutureMarker; +impl UseEffectReturn for T +where + T: Future + 'static, + F: FnOnce() + 'static, +{ + fn apply(self, oncleanup: UseEffectCleanup, cx: &ScopeState) -> Option { + let task = cx.push_future(async move { + let cleanup = self.await; + *oncleanup.borrow_mut() = Some(Box::new(cleanup) as Box); + }); + Some(task) } } diff --git a/packages/hooks/src/use_future.rs b/packages/hooks/src/use_future.rs index 67bcdc74e..b9aa20c31 100644 --- a/packages/hooks/src/use_future.rs +++ b/packages/hooks/src/use_future.rs @@ -31,13 +31,14 @@ where let state = cx.use_hook(move || UseFuture { update: cx.schedule_update(), - needs_regen: Cell::new(true), + needs_regen: Rc::new(Cell::new(true)), state: val.clone(), task: Default::default(), - dependencies: Vec::new(), }); - if dependencies.clone().apply(&mut state.dependencies) || state.needs_regen.get() { + let state_dependencies = cx.use_hook(Vec::new); + + if dependencies.clone().apply(state_dependencies) || state.needs_regen.get() { // kill the old one, if it exists if let Some(task) = state.task.take() { cx.remove_future(task); @@ -69,11 +70,11 @@ pub enum FutureState<'a, T> { Regenerating(&'a T), // the old value } +#[derive(Clone)] pub struct UseFuture { update: Arc, - needs_regen: Cell, + needs_regen: Rc>, task: Rc>>, - dependencies: Vec>, state: UseState>, } diff --git a/packages/hooks/src/use_memo.rs b/packages/hooks/src/use_memo.rs index 3181d57b8..be61b8d53 100644 --- a/packages/hooks/src/use_memo.rs +++ b/packages/hooks/src/use_memo.rs @@ -28,6 +28,7 @@ use crate::UseFutureDep; /// render!(Calculator { number: 0 }) /// } /// ``` +#[must_use = "Consider using `use_effect` to run rerun a callback when dependencies change"] pub fn use_memo(cx: &ScopeState, dependencies: D, callback: impl FnOnce(D::Out) -> T) -> &T where T: 'static, diff --git a/packages/hooks/src/use_ref.rs b/packages/hooks/src/use_ref.rs index cf1b0306f..d0b95074e 100644 --- a/packages/hooks/src/use_ref.rs +++ b/packages/hooks/src/use_ref.rs @@ -110,6 +110,7 @@ use std::{ /// } /// }) /// ``` +#[must_use] pub fn use_ref(cx: &ScopeState, initialize_refcell: impl FnOnce() -> T) -> &UseRef { let hook = cx.use_hook(|| UseRef { update: cx.schedule_update(), diff --git a/packages/hooks/src/use_shared_state.rs b/packages/hooks/src/use_shared_state.rs index 18ce1e5f1..0c0f43508 100644 --- a/packages/hooks/src/use_shared_state.rs +++ b/packages/hooks/src/use_shared_state.rs @@ -26,6 +26,7 @@ macro_rules! debug_location { } pub mod error { + #[cfg(debug_assertions)] fn locations_display(locations: &[&'static std::panic::Location<'static>]) -> String { locations .iter() @@ -158,6 +159,7 @@ impl ProvidedStateInner { /// Any time a component calls `write`, every consumer of the state will be notified - excluding the provider. /// /// Right now, there is not a distinction between read-only and write-only, so every consumer will be notified. +#[must_use] pub fn use_shared_state(cx: &ScopeState) -> Option<&UseSharedState> { let state_owner: &mut Option> = &mut *cx.use_hook(move || { let scope_id = cx.scope_id(); diff --git a/packages/hooks/src/use_state.rs b/packages/hooks/src/use_state.rs index a2da3c885..254493510 100644 --- a/packages/hooks/src/use_state.rs +++ b/packages/hooks/src/use_state.rs @@ -30,6 +30,7 @@ use std::{ /// )) /// } /// ``` +#[must_use] pub fn use_state( cx: &ScopeState, initial_state_fn: impl FnOnce() -> T, diff --git a/packages/hot-reload/Cargo.toml b/packages/hot-reload/Cargo.toml index b724ad685..70057114a 100644 --- a/packages/hot-reload/Cargo.toml +++ b/packages/hot-reload/Cargo.toml @@ -12,7 +12,7 @@ keywords = ["dom", "ui", "gui", "react", "hot-reloading"] [dependencies] dioxus-rsx = { workspace = true } dioxus-core = { workspace = true, features = ["serialize"] } -dioxus-html = { workspace = true } +dioxus-html = { workspace = true, optional = true } interprocess-docfix = { version = "1.2.2" } notify = { version = "5.0.0", optional = true } @@ -24,5 +24,6 @@ once_cell = { version = "1.17.0", optional = true } ignore = { version = "0.4.19", optional = true } [features] -default = [] -file_watcher = ["ignore", "chrono", "notify", "execute", "once_cell", "ignore", "dioxus-html/hot-reload-context"] +default = ["dioxus-html"] +custom_file_watcher = ["ignore", "chrono", "notify", "execute", "once_cell", "ignore"] +file_watcher = ["custom_file_watcher", "dioxus-html/hot-reload-context"] diff --git a/packages/hot-reload/src/file_watcher.rs b/packages/hot-reload/src/file_watcher.rs index 48d97dfa7..0323561fe 100644 --- a/packages/hot-reload/src/file_watcher.rs +++ b/packages/hot-reload/src/file_watcher.rs @@ -13,11 +13,12 @@ use dioxus_rsx::{ }; use interprocess_docfix::local_socket::{LocalSocketListener, LocalSocketStream}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; - -pub use dioxus_html::HtmlCtx; use serde::{Deserialize, Serialize}; -pub struct Config { +#[cfg(feature = "file_watcher")] +use dioxus_html::HtmlCtx; + +pub struct Config { root_path: &'static str, listening_paths: &'static [&'static str], excluded_paths: &'static [&'static str], @@ -39,6 +40,7 @@ impl Default for Config { } } +#[cfg(feature = "file_watcher")] impl Config { pub const fn new() -> Self { Self { @@ -120,7 +122,7 @@ pub fn init(cfg: Config) { } = cfg; if let Ok(crate_dir) = PathBuf::from_str(root_path) { - // try to find the gitingore file + // try to find the gitignore file let gitignore_file_path = crate_dir.join(".gitignore"); let (gitignore, _) = ignore::gitignore::Gitignore::new(gitignore_file_path); @@ -150,21 +152,20 @@ pub fn init(cfg: Config) { } let file_map = Arc::new(Mutex::new(file_map)); + let target_dir = crate_dir.join("target"); + let hot_reload_socket_path = target_dir.join("dioxusin"); + #[cfg(target_os = "macos")] { // On unix, if you force quit the application, it can leave the file socket open // This will cause the local socket listener to fail to open // We check if the file socket is already open from an old session and then delete it - let paths = ["./dioxusin", "./@dioxusin"]; - for path in paths { - let path = PathBuf::from(path); - if path.exists() { - let _ = std::fs::remove_file(path); - } + if hot_reload_socket_path.exists() { + let _ = std::fs::remove_file(hot_reload_socket_path); } } - match LocalSocketListener::bind("@dioxusin") { + match LocalSocketListener::bind(hot_reload_socket_path) { Ok(local_socket_stream) => { let aborted = Arc::new(Mutex::new(false)); diff --git a/packages/hot-reload/src/lib.rs b/packages/hot-reload/src/lib.rs index f7906b141..46efb8341 100644 --- a/packages/hot-reload/src/lib.rs +++ b/packages/hot-reload/src/lib.rs @@ -1,4 +1,7 @@ -use std::io::{BufRead, BufReader}; +use std::{ + io::{BufRead, BufReader}, + path::PathBuf, +}; use dioxus_core::Template; #[cfg(feature = "file_watcher")] @@ -6,9 +9,9 @@ pub use dioxus_html::HtmlCtx; use interprocess_docfix::local_socket::LocalSocketStream; use serde::{Deserialize, Serialize}; -#[cfg(feature = "file_watcher")] +#[cfg(feature = "custom_file_watcher")] mod file_watcher; -#[cfg(feature = "file_watcher")] +#[cfg(feature = "custom_file_watcher")] pub use file_watcher::*; /// A message the hot reloading server sends to the client @@ -24,7 +27,8 @@ pub enum HotReloadMsg { /// Connect to the hot reloading listener. The callback provided will be called every time a template change is detected pub fn connect(mut f: impl FnMut(HotReloadMsg) + Send + 'static) { std::thread::spawn(move || { - if let Ok(socket) = LocalSocketStream::connect("@dioxusin") { + let path = PathBuf::from("./").join("target").join("dioxusin"); + if let Ok(socket) = LocalSocketStream::connect(path) { let mut buf_reader = BufReader::new(socket); loop { let mut buf = String::new(); diff --git a/packages/html/src/elements.rs b/packages/html/src/elements.rs index 6a0e546fa..a42c01950 100644 --- a/packages/html/src/elements.rs +++ b/packages/html/src/elements.rs @@ -74,7 +74,7 @@ macro_rules! impl_attribute_match { $attr:ident $fil:ident: $vil:ident (in $ns:literal), ) => { if $attr == stringify!($fil) { - return Some((stringify!(fil), Some(ns))); + return Some((stringify!(fil), Some($ns))); } }; } @@ -180,14 +180,26 @@ macro_rules! impl_element_match { }; ( - $el:ident $name:ident $namespace:tt { + $el:ident $name:ident $namespace:literal { $( $fil:ident: $vil:ident $extra:tt, )* } ) => { if $el == stringify!($name) { - return Some((stringify!($name), Some(stringify!($namespace)))); + return Some((stringify!($name), Some($namespace))); + } + }; + + ( + $el:ident $name:ident [$_:literal, $namespace:tt] { + $( + $fil:ident: $vil:ident $extra:tt, + )* + } + ) => { + if $el == stringify!($name) { + return Some((stringify!($name), Some($namespace))); } }; } @@ -207,6 +219,8 @@ macro_rules! impl_element_match_attributes { $attr $fil: $vil ($extra), ); )* + + return impl_map_global_attributes!($el $attr $name None); } }; @@ -223,10 +237,41 @@ macro_rules! impl_element_match_attributes { $attr $fil: $vil ($extra), ); )* + + return impl_map_global_attributes!($el $attr $name $namespace); } } } +#[cfg(feature = "hot-reload-context")] +macro_rules! impl_map_global_attributes { + ( + $el:ident $attr:ident $element:ident None + ) => { + map_global_attributes($attr) + }; + + ( + $el:ident $attr:ident $element:ident $namespace:literal + ) => { + if $namespace == "http://www.w3.org/2000/svg" { + map_svg_attributes($attr) + } else { + map_global_attributes($attr) + } + }; + + ( + $el:ident $attr:ident $element:ident [$name:literal, $namespace:tt] + ) => { + if $namespace == "http://www.w3.org/2000/svg" { + map_svg_attributes($attr) + } else { + map_global_attributes($attr) + } + }; +} + macro_rules! builder_constructors { ( $( @@ -254,7 +299,7 @@ macro_rules! builder_constructors { } ); )* - map_global_attributes(attribute).or_else(|| map_svg_attributes(attribute)) + None } fn map_element(element: &str) -> Option<(&'static str, Option<&'static str>)> { @@ -782,6 +827,7 @@ builder_constructors! { decoding: ImageDecoding DEFAULT, height: usize DEFAULT, ismap: Bool DEFAULT, + loading: String DEFAULT, src: Uri DEFAULT, srcset: String DEFAULT, // FIXME this is much more complicated usemap: String DEFAULT, // FIXME should be a fragment starting with '#' diff --git a/packages/html/src/eval.rs b/packages/html/src/eval.rs index c90f5125b..292de70fc 100644 --- a/packages/html/src/eval.rs +++ b/packages/html/src/eval.rs @@ -33,6 +33,7 @@ type EvalCreator = Rc Result>; /// parts is practically asking for a hacker to find an XSS vulnerability in /// it. **This applies especially to web targets, where the JavaScript context /// has access to most, if not all of your application data.** +#[must_use] pub fn use_eval(cx: &ScopeState) -> &EvalCreator { &*cx.use_hook(|| { let eval_provider = cx diff --git a/packages/html/src/global_attributes.rs b/packages/html/src/global_attributes.rs index 3792397ad..02b0b32ed 100644 --- a/packages/html/src/global_attributes.rs +++ b/packages/html/src/global_attributes.rs @@ -269,6 +269,9 @@ trait_methods! { /// azimuth: "azimuth", "style"; + /// + backdrop_filter: "backdrop-filter", "style"; + /// backface_visibility: "backface-visibility", "style"; diff --git a/packages/native-core/src/dioxus.rs b/packages/native-core/src/dioxus.rs index 3e9ff9ee0..c245de4fc 100644 --- a/packages/native-core/src/dioxus.rs +++ b/packages/native-core/src/dioxus.rs @@ -57,7 +57,12 @@ impl DioxusState { node.insert(ElementIdComponent(element_id)); if self.node_id_mapping.len() <= element_id.0 { self.node_id_mapping.resize(element_id.0 + 1, None); + } else if let Some(mut node) = + self.node_id_mapping[element_id.0].and_then(|id| node.real_dom_mut().get_mut(id)) + { + node.remove(); } + self.node_id_mapping[element_id.0] = Some(node_id); } diff --git a/packages/rink/Cargo.toml b/packages/rink/Cargo.toml index ba3d549f8..92d1eae8c 100644 --- a/packages/rink/Cargo.toml +++ b/packages/rink/Cargo.toml @@ -14,7 +14,7 @@ dioxus-html = { workspace = true } dioxus-native-core = { workspace = true, features = ["layout-attributes"] } dioxus-native-core-macro = { workspace = true } -tui = "0.17.0" +ratatui = "0.24.0" crossterm = "0.26.1" anyhow = "1.0.42" tokio = { workspace = true, features = ["full"] } diff --git a/packages/rink/src/lib.rs b/packages/rink/src/lib.rs index 2eba1b98a..4a7261b55 100644 --- a/packages/rink/src/lib.rs +++ b/packages/rink/src/lib.rs @@ -17,6 +17,7 @@ use futures::{channel::mpsc::UnboundedSender, pin_mut, Future, StreamExt}; use futures_channel::mpsc::unbounded; use layout::TaffyLayout; use prevent_default::PreventDefault; +use ratatui::{backend::CrosstermBackend, Terminal}; use std::{io, time::Duration}; use std::{ pin::Pin, @@ -26,7 +27,6 @@ use std::{rc::Rc, sync::RwLock}; use style_attributes::StyleModifier; pub use taffy::{geometry::Point, prelude::*}; use tokio::select; -use tui::{backend::CrosstermBackend, Terminal}; use widgets::{register_widgets, RinkWidgetResponder, RinkWidgetTraitObject}; mod config; @@ -180,7 +180,7 @@ pub fn render( if !to_rerender.is_empty() || updated { updated = false; - fn resize(dims: tui::layout::Rect, taffy: &mut Taffy, rdom: &RealDom) { + fn resize(dims: ratatui::layout::Rect, taffy: &mut Taffy, rdom: &RealDom) { let width = screen_to_layout_space(dims.width); let height = screen_to_layout_space(dims.height); let root_node = rdom @@ -222,7 +222,7 @@ pub fn render( } else { let rdom = rdom.read().unwrap(); resize( - tui::layout::Rect { + ratatui::layout::Rect { x: 0, y: 0, width: 1000, diff --git a/packages/rink/src/render.rs b/packages/rink/src/render.rs index 19d642720..4185f3a62 100644 --- a/packages/rink/src/render.rs +++ b/packages/rink/src/render.rs @@ -1,11 +1,10 @@ use dioxus_native_core::{prelude::*, tree::TreeRef}; -use std::io::Stdout; +use ratatui::{layout::Rect, style::Color}; use taffy::{ geometry::Point, prelude::{Dimension, Layout, Size}, Taffy, }; -use tui::{backend::CrosstermBackend, layout::Rect, style::Color}; use crate::{ focus::Focused, @@ -20,7 +19,7 @@ use crate::{ const RADIUS_MULTIPLIER: [f32; 2] = [1.0, 0.5]; pub(crate) fn render_vnode( - frame: &mut tui::Frame>, + frame: &mut ratatui::Frame, layout: &Taffy, node: NodeRef, cfg: Config, @@ -96,7 +95,7 @@ pub(crate) fn render_vnode( impl RinkWidget for NodeRef<'_> { fn render(self, area: Rect, mut buf: RinkBuffer<'_>) { - use tui::symbols::line::*; + use ratatui::symbols::line::*; enum Direction { Left, diff --git a/packages/rink/src/style.rs b/packages/rink/src/style.rs index 857322079..905cc4b34 100644 --- a/packages/rink/src/style.rs +++ b/packages/rink/src/style.rs @@ -1,6 +1,6 @@ use std::{num::ParseFloatError, str::FromStr}; -use tui::style::{Color, Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use crate::RenderingMode; @@ -442,6 +442,7 @@ impl RinkStyle { impl From for Style { fn from(val: RinkStyle) -> Self { Style { + underline_color: None, fg: val.fg.map(|c| c.color), bg: val.bg.map(|c| c.color), add_modifier: val.add_modifier, diff --git a/packages/rink/src/style_attributes.rs b/packages/rink/src/style_attributes.rs index 0c87e3f83..d1e8c6bee 100644 --- a/packages/rink/src/style_attributes.rs +++ b/packages/rink/src/style_attributes.rs @@ -187,8 +187,8 @@ pub enum BorderStyle { } impl BorderStyle { - pub fn symbol_set(&self) -> Option { - use tui::symbols::line::*; + pub fn symbol_set(&self) -> Option { + use ratatui::symbols::line::*; const DASHED: Set = Set { horizontal: "╌", vertical: "╎", @@ -570,7 +570,7 @@ fn apply_animation(name: &str, _value: &str, _style: &mut StyleModifier) { } fn apply_font(name: &str, value: &str, style: &mut StyleModifier) { - use tui::style::Modifier; + use ratatui::style::Modifier; match name { "font" => (), "font-family" => (), @@ -593,7 +593,7 @@ fn apply_font(name: &str, value: &str, style: &mut StyleModifier) { } fn apply_text(name: &str, value: &str, style: &mut StyleModifier) { - use tui::style::Modifier; + use ratatui::style::Modifier; match name { "text-align" => todo!(), diff --git a/packages/rink/src/widget.rs b/packages/rink/src/widget.rs index ab9044998..91af5b4f6 100644 --- a/packages/rink/src/widget.rs +++ b/packages/rink/src/widget.rs @@ -1,4 +1,4 @@ -use tui::{ +use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier}, diff --git a/packages/router-macro/src/redirect.rs b/packages/router-macro/src/redirect.rs index c1efb8b4d..2933bbc8e 100644 --- a/packages/router-macro/src/redirect.rs +++ b/packages/router-macro/src/redirect.rs @@ -75,6 +75,7 @@ impl Redirect { let (segments, query) = parse_route_segments( path.span(), + #[allow(clippy::map_identity)] closure_arguments.iter().map(|(name, ty)| (name, ty)), &path.value(), )?; diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index 80923d539..da9d3fe85 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -19,6 +19,7 @@ thiserror = { workspace = true } futures-util = { workspace = true } urlencoding = "2.1.3" serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1.0.91", optional = true } url = "2.3.1" wasm-bindgen = { workspace = true, optional = true } web-sys = { version = "0.3.60", optional = true, features = [ @@ -26,18 +27,22 @@ web-sys = { version = "0.3.60", optional = true, features = [ ] } js-sys = { version = "0.3.63", optional = true } gloo-utils = { version = "0.1.6", optional = true } +dioxus-liveview = { workspace = true, optional = true } dioxus-ssr = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"], optional = true } [features] default = ["web"] ssr = ["dioxus-ssr", "tokio"] +liveview = ["dioxus-liveview", "tokio", "dep:serde", "serde_json"] wasm_test = [] serde = ["dep:serde", "gloo-utils/serde"] web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] [dev-dependencies] +axum = { version = "0.6.1", features = ["ws"] } dioxus = { path = "../dioxus" } +dioxus-liveview = { workspace = true, features = ["axum"] } dioxus-ssr = { path = "../ssr" } criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } diff --git a/packages/router/examples/simple_routes.rs b/packages/router/examples/simple_routes.rs index 3634f7df3..aac1e8658 100644 --- a/packages/router/examples/simple_routes.rs +++ b/packages/router/examples/simple_routes.rs @@ -2,6 +2,47 @@ use dioxus::prelude::*; use dioxus_router::prelude::*; use std::str::FromStr; +#[cfg(feature = "liveview")] +#[tokio::main] +async fn main() { + use axum::{extract::ws::WebSocketUpgrade, response::Html, routing::get, Router}; + + let listen_address: std::net::SocketAddr = ([127, 0, 0, 1], 3030).into(); + let view = dioxus_liveview::LiveViewPool::new(); + let app = Router::new() + .fallback(get(move || async move { + Html(format!( + r#" + + + +

+ {glue} + + "#, + glue = dioxus_liveview::interpreter_glue(&format!("ws://{listen_address}/ws")) + )) + })) + .route( + "/ws", + get(move |ws: WebSocketUpgrade| async move { + ws.on_upgrade(move |socket| async move { + _ = view + .launch(dioxus_liveview::axum_socket(socket), Root) + .await; + }) + }), + ); + + println!("Listening on http://{listen_address}"); + + axum::Server::bind(&listen_address.to_string().parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +#[cfg(not(feature = "liveview"))] fn main() { #[cfg(not(target_arch = "wasm32"))] dioxus_desktop::launch(Root); @@ -10,21 +51,26 @@ fn main() { dioxus_web::launch(root); } +#[cfg(feature = "liveview")] #[component] fn Root(cx: Scope) -> Element { - render! { - Router:: {} - } + let history = LiveviewHistory::new(cx); + render! { Router:: { + config: || RouterConfig::default().history(history), + } } +} + +#[cfg(not(feature = "liveview"))] +#[component] +fn Root(cx: Scope) -> Element { + render! { Router:: {} } } #[component] fn UserFrame(cx: Scope, user_id: usize) -> Element { render! { - pre { - "UserFrame{{\n\tuser_id:{user_id}\n}}" - } - div { - background_color: "rgba(0,0,0,50%)", + pre { "UserFrame{{\n\tuser_id:{user_id}\n}}" } + div { background_color: "rgba(0,0,0,50%)", "children:" Outlet:: {} } @@ -88,6 +134,16 @@ fn Route3(cx: Scope, dynamic: String) -> Element { to: Route::Route2 { user_id: 8888 }, "hello world link" } + button { + disabled: !navigator.can_go_back(), + onclick: move |_| { navigator.go_back(); }, + "go back" + } + button { + disabled: !navigator.can_go_forward(), + onclick: move |_| { navigator.go_forward(); }, + "go forward" + } button { onclick: move |_| { navigator.push("https://www.google.com"); }, "google link" diff --git a/packages/router/src/components/history_buttons.rs b/packages/router/src/components/history_buttons.rs index eb62e9285..ae11ad276 100644 --- a/packages/router/src/components/history_buttons.rs +++ b/packages/router/src/components/history_buttons.rs @@ -142,7 +142,7 @@ pub fn GoForwardButton<'a>(cx: Scope<'a, HistoryButtonProps<'a>>) -> Element { } }; - let disabled = !router.can_go_back(); + let disabled = !router.can_go_forward(); render! { button { diff --git a/packages/router/src/components/link.rs b/packages/router/src/components/link.rs index 787f9cdc9..639d99ae7 100644 --- a/packages/router/src/components/link.rs +++ b/packages/router/src/components/link.rs @@ -11,6 +11,8 @@ use crate::navigation::NavigationTarget; use crate::prelude::Routable; use crate::utils::use_router_internal::use_router_internal; +use url::Url; + /// Something that can be converted into a [`NavigationTarget`]. #[derive(Clone)] pub enum IntoRoutable { @@ -53,6 +55,18 @@ impl From<&str> for IntoRoutable { } } +impl From for IntoRoutable { + fn from(url: Url) -> Self { + IntoRoutable::FromStr(url.to_string()) + } +} + +impl From<&Url> for IntoRoutable { + fn from(url: &Url) -> Self { + IntoRoutable::FromStr(url.to_string()) + } +} + /// The properties for a [`Link`]. #[derive(Props)] pub struct LinkProps<'a> { diff --git a/packages/router/src/contexts/router.rs b/packages/router/src/contexts/router.rs index b15956991..079640e61 100644 --- a/packages/router/src/contexts/router.rs +++ b/packages/router/src/contexts/router.rs @@ -232,7 +232,7 @@ impl RouterContext { IntoRoutable::FromStr(url) => { let parsed_route: NavigationTarget> = match self.route_from_str(&url) { Ok(route) => NavigationTarget::Internal(route), - Err(err) => NavigationTarget::External(err), + Err(_) => NavigationTarget::External(url), }; parsed_route } diff --git a/packages/router/src/history/liveview.rs b/packages/router/src/history/liveview.rs new file mode 100644 index 000000000..9ffb09c34 --- /dev/null +++ b/packages/router/src/history/liveview.rs @@ -0,0 +1,441 @@ +use super::HistoryProvider; +use crate::routable::Routable; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::{Mutex, RwLock}; +use std::{collections::BTreeMap, rc::Rc, str::FromStr, sync::Arc}; + +/// A [`HistoryProvider`] that evaluates history through JS. +pub struct LiveviewHistory +where + ::Err: std::fmt::Display, +{ + action_tx: tokio::sync::mpsc::UnboundedSender>, + timeline: Arc>>, + updater_callback: Arc>>, +} + +struct Timeline +where + ::Err: std::fmt::Display, +{ + current_index: usize, + routes: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug)] +struct State { + index: usize, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Session +where + ::Err: std::fmt::Display, +{ + #[serde(with = "routes")] + routes: BTreeMap, + last_visited: usize, +} + +#[derive(Serialize, Deserialize)] +struct SessionStorage { + liveview: Option, +} + +enum Action { + GoBack, + GoForward, + Push(R), + Replace(R), + External(String), +} + +impl Timeline +where + ::Err: std::fmt::Display, +{ + fn new(initial_path: R) -> Self { + Self { + current_index: 0, + routes: BTreeMap::from([(0, initial_path)]), + } + } + + fn init( + &mut self, + route: R, + state: Option, + session: Option>, + depth: usize, + ) -> State { + if let Some(session) = session { + self.routes = session.routes; + if state.is_none() { + // top of stack + let last_visited = session.last_visited; + self.routes.retain(|&lhs, _| lhs <= last_visited); + } + }; + let state = match state { + Some(state) => { + self.current_index = state.index; + state + } + None => { + let index = depth - 1; + self.current_index = index; + State { index } + } + }; + self.routes.insert(state.index, route); + state + } + + fn update(&mut self, route: R, state: Option) -> State { + if let Some(state) = state { + self.current_index = state.index; + self.routes.insert(self.current_index, route); + state + } else { + self.push(route) + } + } + + fn push(&mut self, route: R) -> State { + // top of stack + let index = self.current_index + 1; + self.current_index = index; + self.routes.insert(index, route); + self.routes.retain(|&rhs, _| index >= rhs); + State { + index: self.current_index, + } + } + + fn replace(&mut self, route: R) -> State { + self.routes.insert(self.current_index, route); + State { + index: self.current_index, + } + } + + fn current_route(&self) -> &R { + &self.routes[&self.current_index] + } + + fn session(&self) -> Session { + Session { + routes: self.routes.clone(), + last_visited: self.current_index, + } + } +} + +impl LiveviewHistory +where + ::Err: std::fmt::Display, +{ + /// Create a [`LiveviewHistory`] in the given scope. + /// When using a [`LiveviewHistory`] in combination with use_eval, history must be untampered with. + /// + /// # Panics + /// + /// Panics if not in a Liveview context. + pub fn new(cx: &ScopeState) -> Self { + Self::new_with_initial_path( + cx, + "/".parse().unwrap_or_else(|err| { + panic!("index route does not exist:\n{}\n use LiveviewHistory::new_with_initial_path to set a custom path", err) + }), + ) + } + + /// Create a [`LiveviewHistory`] in the given scope, starting at `initial_path`. + /// When using a [`LiveviewHistory`] in combination with use_eval, history must be untampered with. + /// + /// # Panics + /// + /// Panics if not in a Liveview context. + pub fn new_with_initial_path(cx: &ScopeState, initial_path: R) -> Self { + let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel::>(); + let action_rx = Arc::new(Mutex::new(action_rx)); + let timeline = Arc::new(Mutex::new(Timeline::new(initial_path))); + let updater_callback: Arc>> = + Arc::new(RwLock::new(Arc::new(|| {}))); + + let eval_provider = cx + .consume_context::>() + .expect("evaluator not provided"); + + let create_eval = Rc::new(move |script: &str| { + eval_provider + .new_evaluator(script.to_string()) + .map(UseEval::new) + }) as Rc Result>; + + // Listen to server actions + cx.push_future({ + let timeline = timeline.clone(); + let action_rx = action_rx.clone(); + let create_eval = create_eval.clone(); + async move { + let mut action_rx = action_rx.lock().expect("unpoisoned mutex"); + loop { + let eval = action_rx.recv().await.expect("sender to exist"); + let _ = match eval { + Action::GoBack => create_eval( + r#" + // this triggers a PopState event + history.back(); + "#, + ), + Action::GoForward => create_eval( + r#" + // this triggers a PopState event + history.forward(); + "#, + ), + Action::Push(route) => { + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.push(route.clone()); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + create_eval(&format!( + r#" + // this does not trigger a PopState event + history.pushState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "# + )) + } + Action::Replace(route) => { + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.replace(route.clone()); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + create_eval(&format!( + r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "# + )) + } + Action::External(url) => create_eval(&format!( + r#" + location.href = "{url}"; + "# + )), + }; + } + } + }); + + // Listen to browser actions + cx.push_future({ + let updater = updater_callback.clone(); + let timeline = timeline.clone(); + let create_eval = create_eval.clone(); + async move { + let popstate_eval = { + let init_eval = create_eval( + r#" + return [ + document.location.pathname + "?" + document.location.search + "\#" + document.location.hash, + history.state, + JSON.parse(sessionStorage.getItem("liveview")), + history.length, + ]; + "#, + ).expect("failed to load state").await.expect("serializable state"); + let (route, state, session, depth) = serde_json::from_value::<( + String, + Option, + Option>, + usize, + )>(init_eval).expect("serializable state"); + let Ok(route) = R::from_str(&route.to_string()) else { + return; + }; + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.init(route.clone(), state, session, depth); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + + // Call the updater callback + (updater.read().unwrap())(); + + create_eval(&format!(r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + + window.addEventListener("popstate", (event) => {{ + dioxus.send([ + document.location.pathname + "?" + document.location.search + "\#" + document.location.hash, + event.state, + ]); + }}); + "#)).expect("failed to initialize popstate") + }; + + loop { + let event = match popstate_eval.recv().await { + Ok(event) => event, + Err(_) => continue, + }; + let (route, state) = serde_json::from_value::<(String, Option)>(event).expect("serializable state"); + let Ok(route) = R::from_str(&route.to_string()) else { + return; + }; + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.update(route.clone(), state); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + + let _ = create_eval(&format!( + r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "#)); + + // Call the updater callback + (updater.read().unwrap())(); + } + } + }); + + Self { + action_tx, + timeline, + updater_callback, + } + } +} + +impl HistoryProvider for LiveviewHistory +where + ::Err: std::fmt::Display, +{ + fn go_back(&mut self) { + let _ = self.action_tx.send(Action::GoBack); + } + + fn go_forward(&mut self) { + let _ = self.action_tx.send(Action::GoForward); + } + + fn push(&mut self, route: R) { + let _ = self.action_tx.send(Action::Push(route)); + } + + fn replace(&mut self, route: R) { + let _ = self.action_tx.send(Action::Replace(route)); + } + + fn external(&mut self, url: String) -> bool { + let _ = self.action_tx.send(Action::External(url)); + true + } + + fn current_route(&self) -> R { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + timeline.current_route().clone() + } + + fn can_go_back(&self) -> bool { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + // Check if the one before is contiguous (i.e., not an external page) + let visited_indices: Vec = timeline.routes.keys().cloned().collect(); + visited_indices + .iter() + .position(|&rhs| timeline.current_index == rhs) + .map_or(false, |index| { + index > 0 && visited_indices[index - 1] == timeline.current_index - 1 + }) + } + + fn can_go_forward(&self) -> bool { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + // Check if the one after is contiguous (i.e., not an external page) + let visited_indices: Vec = timeline.routes.keys().cloned().collect(); + visited_indices + .iter() + .rposition(|&rhs| timeline.current_index == rhs) + .map_or(false, |index| { + index < visited_indices.len() - 1 + && visited_indices[index + 1] == timeline.current_index + 1 + }) + } + + fn updater(&mut self, callback: Arc) { + let mut updater_callback = self.updater_callback.write().unwrap(); + *updater_callback = callback; + } +} + +mod routes { + use crate::prelude::Routable; + use core::str::FromStr; + use serde::de::{MapAccess, Visitor}; + use serde::{ser::SerializeMap, Deserializer, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize(routes: &BTreeMap, serializer: S) -> Result + where + S: Serializer, + R: Routable, + { + let mut map = serializer.serialize_map(Some(routes.len()))?; + for (index, route) in routes.iter() { + map.serialize_entry(&index.to_string(), &route.to_string())?; + } + map.end() + } + + pub fn deserialize<'de, D, R>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + R: Routable, + ::Err: std::fmt::Display, + { + struct BTreeMapVisitor { + marker: std::marker::PhantomData, + } + + impl<'de, R> Visitor<'de> for BTreeMapVisitor + where + R: Routable, + ::Err: std::fmt::Display, + { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with indices and routable values") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut routes = BTreeMap::new(); + while let Some((index, route)) = map.next_entry::()? { + let index = index.parse::().map_err(serde::de::Error::custom)?; + let route = R::from_str(&route).map_err(serde::de::Error::custom)?; + routes.insert(index, route); + } + Ok(routes) + } + } + + deserializer.deserialize_map(BTreeMapVisitor { + marker: std::marker::PhantomData, + }) + } +} diff --git a/packages/router/src/history/mod.rs b/packages/router/src/history/mod.rs index e9668b979..cf7183366 100644 --- a/packages/router/src/history/mod.rs +++ b/packages/router/src/history/mod.rs @@ -22,6 +22,11 @@ pub use web::*; #[cfg(feature = "web")] pub(crate) mod web_history; +#[cfg(feature = "liveview")] +mod liveview; +#[cfg(feature = "liveview")] +pub use liveview::*; + // #[cfg(feature = "web")] // mod web_hash; // #[cfg(feature = "web")] diff --git a/packages/router/src/hooks/use_navigator.rs b/packages/router/src/hooks/use_navigator.rs index c1c21ee63..a05cd036f 100644 --- a/packages/router/src/hooks/use_navigator.rs +++ b/packages/router/src/hooks/use_navigator.rs @@ -48,6 +48,7 @@ use crate::prelude::{Navigator, RouterContext}; /// # let mut vdom = VirtualDom::new(App); /// # let _ = vdom.rebuild(); /// ``` +#[must_use] pub fn use_navigator(cx: &ScopeState) -> &Navigator { &*cx.use_hook(|| { let router = cx diff --git a/packages/router/src/hooks/use_route.rs b/packages/router/src/hooks/use_route.rs index 42cca27fb..51d78cfca 100644 --- a/packages/router/src/hooks/use_route.rs +++ b/packages/router/src/hooks/use_route.rs @@ -46,6 +46,7 @@ use crate::utils::use_router_internal::use_router_internal; /// # let _ = vdom.rebuild(); /// # assert_eq!(dioxus_ssr::render(&vdom), "

App

Current Path

/

") /// ``` +#[must_use] pub fn use_route(cx: &ScopeState) -> Option { match use_router_internal(cx) { Some(r) => Some(r.current()), diff --git a/packages/router/src/hooks/use_router.rs b/packages/router/src/hooks/use_router.rs index 0891ebf97..5f1f13e45 100644 --- a/packages/router/src/hooks/use_router.rs +++ b/packages/router/src/hooks/use_router.rs @@ -3,6 +3,7 @@ use dioxus::prelude::ScopeState; use crate::{prelude::RouterContext, utils::use_router_internal::use_router_internal}; #[deprecated = "prefer the use_navigator or use_route functions"] +#[must_use] /// A hook that provides access to information about the router. pub fn use_router(cx: &ScopeState) -> &RouterContext { use_router_internal(cx) diff --git a/packages/rsx/Cargo.toml b/packages/rsx/Cargo.toml index bc866acb7..72b51a051 100644 --- a/packages/rsx/Cargo.toml +++ b/packages/rsx/Cargo.toml @@ -20,6 +20,7 @@ quote = { version = "1.0" } serde = { version = "1.0", features = ["derive"], optional = true } internment = { version = "0.7.0", optional = true } krates = { version = "0.12.6", optional = true } +tracing.workspace = true [features] hot_reload = ["krates", "internment"] diff --git a/packages/rsx/src/hot_reload/hot_reload_diff.rs b/packages/rsx/src/hot_reload/hot_reload_diff.rs index 22a01875d..7e19d3aad 100644 --- a/packages/rsx/src/hot_reload/hot_reload_diff.rs +++ b/packages/rsx/src/hot_reload/hot_reload_diff.rs @@ -1,4 +1,5 @@ use proc_macro2::TokenStream; +use quote::ToTokens; use syn::{File, Macro}; pub enum DiffResult { @@ -10,13 +11,30 @@ pub enum DiffResult { pub fn find_rsx(new: &File, old: &File) -> DiffResult { let mut rsx_calls = Vec::new(); if new.items.len() != old.items.len() { + tracing::trace!( + "found not hot reload-able change {:#?} != {:#?}", + new.items + .iter() + .map(|i| i.to_token_stream().to_string()) + .collect::>(), + old.items + .iter() + .map(|i| i.to_token_stream().to_string()) + .collect::>() + ); return DiffResult::CodeChanged; } for (new, old) in new.items.iter().zip(old.items.iter()) { if find_rsx_item(new, old, &mut rsx_calls) { + tracing::trace!( + "found not hot reload-able change {:#?} != {:#?}", + new.to_token_stream().to_string(), + old.to_token_stream().to_string() + ); return DiffResult::CodeChanged; } } + tracing::trace!("found hot reload-able changes {:#?}", rsx_calls); DiffResult::RsxChanged(rsx_calls) } @@ -94,6 +112,9 @@ fn find_rsx_item( (syn::ImplItem::Macro(new_item), syn::ImplItem::Macro(old_item)) => { old_item != new_item } + (syn::ImplItem::Verbatim(stream), syn::ImplItem::Verbatim(stream2)) => { + stream.to_string() != stream2.to_string() + } _ => true, } { return true; @@ -186,10 +207,12 @@ fn find_rsx_trait( } } (syn::TraitItem::Fn(new_item), syn::TraitItem::Fn(old_item)) => { - if let (Some(new_block), Some(old_block)) = (&new_item.default, &old_item.default) { - find_rsx_block(new_block, old_block, rsx_calls) - } else { - true + match (&new_item.default, &old_item.default) { + (Some(new_block), Some(old_block)) => { + find_rsx_block(new_block, old_block, rsx_calls) + } + (None, None) => false, + _ => true, } } (syn::TraitItem::Type(new_item), syn::TraitItem::Type(old_item)) => { @@ -198,6 +221,9 @@ fn find_rsx_trait( (syn::TraitItem::Macro(new_item), syn::TraitItem::Macro(old_item)) => { old_item != new_item } + (syn::TraitItem::Verbatim(stream), syn::TraitItem::Verbatim(stream2)) => { + stream.to_string() != stream2.to_string() + } _ => true, } { return true; @@ -355,6 +381,11 @@ fn find_rsx_expr( || new_expr.or2_token != old_expr.or2_token || new_expr.output != old_expr.output } + (syn::Expr::Const(new_expr), syn::Expr::Const(old_expr)) => { + find_rsx_block(&new_expr.block, &old_expr.block, rsx_calls) + || new_expr.attrs != old_expr.attrs + || new_expr.const_token != old_expr.const_token + } (syn::Expr::Continue(new_expr), syn::Expr::Continue(old_expr)) => old_expr != new_expr, (syn::Expr::Field(new_expr), syn::Expr::Field(old_expr)) => { find_rsx_expr(&new_expr.base, &old_expr.base, rsx_calls) @@ -402,6 +433,7 @@ fn find_rsx_expr( || new_expr.attrs != old_expr.attrs || new_expr.bracket_token != old_expr.bracket_token } + (syn::Expr::Infer(new_expr), syn::Expr::Infer(old_expr)) => new_expr != old_expr, (syn::Expr::Let(new_expr), syn::Expr::Let(old_expr)) => { find_rsx_expr(&new_expr.expr, &old_expr.expr, rsx_calls) || new_expr.attrs != old_expr.attrs @@ -589,7 +621,9 @@ fn find_rsx_expr( _ => true, } } - (syn::Expr::Verbatim(_), syn::Expr::Verbatim(_)) => false, + (syn::Expr::Verbatim(stream), syn::Expr::Verbatim(stream2)) => { + stream.to_string() != stream2.to_string() + } _ => true, } } diff --git a/packages/rsx/src/hot_reload/hot_reloading_file_map.rs b/packages/rsx/src/hot_reload/hot_reloading_file_map.rs index 0393142b4..7e36dddf9 100644 --- a/packages/rsx/src/hot_reload/hot_reloading_file_map.rs +++ b/packages/rsx/src/hot_reload/hot_reloading_file_map.rs @@ -51,17 +51,24 @@ impl FileMap { fn find_rs_files( root: PathBuf, filter: &mut impl FnMut(&Path) -> bool, - ) -> io::Result { + ) -> FileMapSearchResult { let mut files = HashMap::new(); let mut errors = Vec::new(); if root.is_dir() { - for entry in (fs::read_dir(root)?).flatten() { + let read_dir = match fs::read_dir(root) { + Ok(read_dir) => read_dir, + Err(err) => { + errors.push(err); + return FileMapSearchResult { map: files, errors }; + } + }; + for entry in read_dir.flatten() { let path = entry.path(); if !filter(&path) { let FileMapSearchResult { map, errors: child_errors, - } = find_rs_files(path, filter)?; + } = find_rs_files(path, filter); errors.extend(child_errors); files.extend(map); } @@ -69,14 +76,20 @@ impl FileMap { } else if root.extension().and_then(|s| s.to_str()) == Some("rs") { if let Ok(mut file) = File::open(root.clone()) { let mut src = String::new(); - file.read_to_string(&mut src)?; - files.insert(root, (src, None)); + match file.read_to_string(&mut src) { + Ok(_) => { + files.insert(root, (src, None)); + } + Err(err) => { + errors.push(err); + } + } } } - Ok(FileMapSearchResult { map: files, errors }) + FileMapSearchResult { map: files, errors } } - let FileMapSearchResult { map, errors } = find_rs_files(path, &mut filter)?; + let FileMapSearchResult { map, errors } = find_rs_files(path, &mut filter); let result = Self { map, in_workspace: HashMap::new(), diff --git a/packages/rsx/src/lib.rs b/packages/rsx/src/lib.rs index bdf32b272..5db6baee9 100644 --- a/packages/rsx/src/lib.rs +++ b/packages/rsx/src/lib.rs @@ -193,7 +193,7 @@ impl<'a> ToTokens for TemplateRenderer<'a> { fn to_tokens(&self, out_tokens: &mut TokenStream2) { let mut context = DynamicContext::default(); - let key = match self.roots.get(0) { + let key = match self.roots.first() { Some(BodyNode::Element(el)) if self.roots.len() == 1 => el.key.clone(), Some(BodyNode::Component(comp)) if self.roots.len() == 1 => comp.key().cloned(), _ => None, diff --git a/packages/rsx/src/node.rs b/packages/rsx/src/node.rs index 1bda081d9..ed5e0bff4 100644 --- a/packages/rsx/src/node.rs +++ b/packages/rsx/src/node.rs @@ -125,7 +125,10 @@ impl ToTokens for BodyNode { __cx.text_node(#txt) }), BodyNode::RawExpr(exp) => tokens.append_all(quote! { - __cx.make_node(#exp) + { + let ___nodes = (#exp).into_vnode(__cx); + ___nodes + } }), BodyNode::ForLoop(exp) => { let ForLoop { @@ -137,16 +140,23 @@ impl ToTokens for BodyNode { location: None, }; + // Signals expose an issue with temporary lifetimes + // We need to directly render out the nodes first to collapse their lifetime to <'a> + // And then we can return them into the dyn loop tokens.append_all(quote! { - __cx.make_node( - (#expr).into_iter().map(|#pat| { #renderer }) - ) + { + let ___nodes =(#expr).into_iter().map(|#pat| { #renderer }).into_vnode(__cx); + ___nodes + } }) } BodyNode::IfChain(chain) => { if is_if_chain_terminated(chain) { tokens.append_all(quote! { - __cx.make_node(#chain) + { + let ___nodes = (#chain).into_vnode(__cx); + ___nodes + } }); } else { let ExprIf { @@ -200,7 +210,10 @@ impl ToTokens for BodyNode { }); tokens.append_all(quote! { - __cx.make_node(#body) + { + let ___nodes = (#body).into_vnode(__cx); + ___nodes + } }); } } diff --git a/packages/server-macro/Cargo.toml b/packages/server-macro/Cargo.toml index 5a38acb40..a91cc626a 100644 --- a/packages/server-macro/Cargo.toml +++ b/packages/server-macro/Cargo.toml @@ -17,7 +17,7 @@ proc-macro2 = "^1.0.63" quote = "^1.0.26" syn = { version = "2", features = ["full"] } convert_case = "^0.6.0" -server_fn_macro = "^0.4.6" +server_fn_macro = "^0.5.2" [lib] proc-macro = true diff --git a/packages/signals/Cargo.toml b/packages/signals/Cargo.toml index 56e19bbd8..c85fb6d7f 100644 --- a/packages/signals/Cargo.toml +++ b/packages/signals/Cargo.toml @@ -1,8 +1,14 @@ [package] name = "dioxus-signals" authors = ["Jonathan Kelley"] -version = "0.0.0" +version = "0.4.3" edition = "2018" +description = "Signals for Dioxus" +license = "MIT OR Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +keywords = ["dom", "ui", "gui", "react", "wasm"] +rust-version = "1.60.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -20,4 +26,4 @@ tokio = { version = "1", features = ["full"] } [features] default = [] -serialize = ["serde"] \ No newline at end of file +serialize = ["serde"] diff --git a/packages/signals/examples/split_subscriptions.rs b/packages/signals/examples/split_subscriptions.rs index 3eb60ed29..bb286b4b7 100644 --- a/packages/signals/examples/split_subscriptions.rs +++ b/packages/signals/examples/split_subscriptions.rs @@ -22,15 +22,9 @@ fn app(cx: Scope) -> Element { use_context_provider(cx, ApplicationData::default); render! { - div { - ReadsFirst {} - } - div { - ReadsSecond {} - } - div { - ReadsManySignals {} - } + div { ReadsFirst {} } + div { ReadsSecond {} } + div { ReadsManySignals {} } } } @@ -107,16 +101,14 @@ fn ReadsManySignals(cx: Scope) -> Element { } button { onclick: move |_| { - if let Some(first) = data.many_signals.read().get(0) { + if let Some(first) = data.many_signals.read().first() { *first.write() += 1; } }, "Increase First Item" } for signal in data.many_signals { - Child { - count: signal, - } + Child { count: signal } } } } diff --git a/packages/signals/src/effect.rs b/packages/signals/src/effect.rs index 9a54188d9..e09e58cf0 100644 --- a/packages/signals/src/effect.rs +++ b/packages/signals/src/effect.rs @@ -1,16 +1,28 @@ use core::{self, fmt::Debug}; -use std::cell::RefCell; use std::fmt::{self, Formatter}; -use std::rc::Rc; // use dioxus_core::prelude::*; use crate::use_signal; use crate::{dependency::Dependency, CopyValue}; -#[derive(Default, Clone)] +#[derive(Copy, Clone, PartialEq)] pub(crate) struct EffectStack { - pub(crate) effects: Rc>>, + pub(crate) effects: CopyValue>, +} + +impl Default for EffectStack { + fn default() -> Self { + Self { + effects: CopyValue::new_in_scope(Vec::new(), ScopeId::ROOT), + } + } +} + +impl EffectStack { + pub(crate) fn current(&self) -> Option { + self.effects.read().last().copied() + } } pub(crate) fn get_effect_stack() -> EffectStack { @@ -18,7 +30,7 @@ pub(crate) fn get_effect_stack() -> EffectStack { Some(rt) => rt, None => { let store = EffectStack::default(); - provide_root_context(store.clone()); + provide_root_context(store); store } } @@ -57,6 +69,7 @@ pub fn use_effect_with_dependencies( pub struct Effect { pub(crate) source: ScopeId, pub(crate) callback: CopyValue>, + pub(crate) effect_stack: EffectStack, } impl Debug for Effect { @@ -67,7 +80,7 @@ impl Debug for Effect { impl Effect { pub(crate) fn current() -> Option { - get_effect_stack().effects.borrow().last().copied() + get_effect_stack().effects.read().last().copied() } /// Create a new effect. The effect will be run immediately and whenever any signal it reads changes. @@ -77,6 +90,7 @@ impl Effect { let myself = Self { source: current_scope_id().expect("in a virtual dom"), callback: CopyValue::new(Box::new(callback)), + effect_stack: get_effect_stack(), }; myself.try_run(); @@ -86,13 +100,13 @@ impl Effect { /// Run the effect callback immediately. Returns `true` if the effect was run. Returns `false` is the effect is dead. pub fn try_run(&self) { - if let Some(mut callback) = self.callback.try_write() { + if let Ok(mut callback) = self.callback.try_write() { { - get_effect_stack().effects.borrow_mut().push(*self); + self.effect_stack.effects.write().push(*self); } callback(); { - get_effect_stack().effects.borrow_mut().pop(); + self.effect_stack.effects.write().pop(); } } } diff --git a/packages/signals/src/impls.rs b/packages/signals/src/impls.rs index 6427c2e2b..077777c40 100644 --- a/packages/signals/src/impls.rs +++ b/packages/signals/src/impls.rs @@ -1,7 +1,7 @@ use crate::rt::CopyValue; use crate::signal::{ReadOnlySignal, Signal, Write}; - -use std::cell::{Ref, RefMut}; +use generational_box::GenerationalRef; +use generational_box::GenerationalRefMut; use std::{ fmt::{Debug, Display}, @@ -38,8 +38,8 @@ macro_rules! read_impls { impl $ty> { /// Read a value from the inner vector. - pub fn get(&self, index: usize) -> Option> { - Ref::filter_map(self.read(), |v| v.get(index)).ok() + pub fn get(&self, index: usize) -> Option> { + GenerationalRef::filter_map(self.read(), |v| v.get(index)) } } @@ -52,9 +52,9 @@ macro_rules! read_impls { self.with(|v| v.clone()).unwrap() } - /// Attemps to read the inner value of the Option. - pub fn as_ref(&self) -> Option> { - Ref::filter_map(self.read(), |v| v.as_ref()).ok() + /// Attempts to read the inner value of the Option. + pub fn as_ref(&self) -> Option> { + GenerationalRef::filter_map(self.read(), |v| v.as_ref()) } } }; @@ -182,19 +182,19 @@ macro_rules! write_impls { } /// Gets the value out of the Option, or inserts the given value if the Option is empty. - pub fn get_or_insert(&self, default: T) -> Ref<'_, T> { + pub fn get_or_insert(&self, default: T) -> GenerationalRef { self.get_or_insert_with(|| default) } /// Gets the value out of the Option, or inserts the value returned by the given function if the Option is empty. - pub fn get_or_insert_with(&self, default: impl FnOnce() -> T) -> Ref<'_, T> { + pub fn get_or_insert_with(&self, default: impl FnOnce() -> T) -> GenerationalRef { let borrow = self.read(); if borrow.is_none() { drop(borrow); self.with_mut(|v| *v = Some(default())); - Ref::map(self.read(), |v| v.as_ref().unwrap()) + GenerationalRef::map(self.read(), |v| v.as_ref().unwrap()) } else { - Ref::map(borrow, |v| v.as_ref().unwrap()) + GenerationalRef::map(borrow, |v| v.as_ref().unwrap()) } } } @@ -238,15 +238,15 @@ impl IntoIterator for CopyValue> { impl CopyValue> { /// Write to an element in the inner vector. - pub fn get_mut(&self, index: usize) -> Option> { - RefMut::filter_map(self.write(), |v| v.get_mut(index)).ok() + pub fn get_mut(&self, index: usize) -> Option> { + GenerationalRefMut::filter_map(self.write(), |v| v.get_mut(index)) } } impl CopyValue> { /// Deref the inner value mutably. - pub fn as_mut(&self) -> Option> { - RefMut::filter_map(self.write(), |v| v.as_mut()).ok() + pub fn as_mut(&self) -> Option> { + GenerationalRefMut::filter_map(self.write(), |v| v.as_mut()) } } @@ -281,14 +281,14 @@ impl IntoIterator for Signal> { impl Signal> { /// Returns a reference to an element or `None` if out of bounds. - pub fn get_mut(&self, index: usize) -> Option>> { + pub fn get_mut(&self, index: usize) -> Option>> { Write::filter_map(self.write(), |v| v.get_mut(index)) } } impl Signal> { /// Returns a reference to an element or `None` if out of bounds. - pub fn as_mut(&self) -> Option>> { + pub fn as_mut(&self) -> Option>> { Write::filter_map(self.write(), |v| v.as_mut()) } } diff --git a/packages/signals/src/rt.rs b/packages/signals/src/rt.rs index 113fe9b30..b0c09d7f1 100644 --- a/packages/signals/src/rt.rs +++ b/packages/signals/src/rt.rs @@ -1,11 +1,13 @@ -use std::cell::{Ref, RefMut}; - +use std::mem::MaybeUninit; +use std::ops::Deref; use std::rc::Rc; use dioxus_core::prelude::*; use dioxus_core::ScopeId; -use generational_box::{GenerationalBox, Owner, Store}; +use generational_box::{ + BorrowError, BorrowMutError, GenerationalBox, GenerationalRef, GenerationalRefMut, Owner, Store, +}; use crate::Effect; @@ -81,6 +83,7 @@ impl CopyValue { /// Create a new CopyValue. The value will be stored in the current component. /// /// Once the component this value is created in is dropped, the value will be dropped. + #[track_caller] pub fn new(value: T) -> Self { let owner = current_owner(); @@ -90,6 +93,22 @@ impl CopyValue { } } + pub(crate) fn new_with_caller( + value: T, + #[cfg(debug_assertions)] caller: &'static std::panic::Location<'static>, + ) -> Self { + let owner = current_owner(); + + Self { + value: owner.insert_with_caller( + value, + #[cfg(debug_assertions)] + caller, + ), + origin_scope: current_scope_id().expect("in a virtual dom"), + } + } + /// Create a new CopyValue. The value will be stored in the given scope. When the specified scope is dropped, the value will be dropped. pub fn new_in_scope(value: T, scope: ScopeId) -> Self { let owner = owner_in_scope(scope); @@ -115,22 +134,26 @@ impl CopyValue { } /// Try to read the value. If the value has been dropped, this will return None. - pub fn try_read(&self) -> Option> { + #[track_caller] + pub fn try_read(&self) -> Result, BorrowError> { self.value.try_read() } /// Read the value. If the value has been dropped, this will panic. - pub fn read(&self) -> Ref<'_, T> { + #[track_caller] + pub fn read(&self) -> GenerationalRef { self.value.read() } /// Try to write the value. If the value has been dropped, this will return None. - pub fn try_write(&self) -> Option> { + #[track_caller] + pub fn try_write(&self) -> Result, BorrowMutError> { self.value.try_write() } /// Write the value. If the value has been dropped, this will panic. - pub fn write(&self) -> RefMut<'_, T> { + #[track_caller] + pub fn write(&self) -> GenerationalRefMut { self.value.write() } @@ -164,3 +187,36 @@ impl PartialEq for CopyValue { self.value.ptr_eq(&other.value) } } + +impl Deref for CopyValue { + type Target = dyn Fn() -> GenerationalRef; + + fn deref(&self) -> &Self::Target { + // https://github.com/dtolnay/case-studies/tree/master/callable-types + + // First we create a closure that captures something with the Same in memory layout as Self (MaybeUninit). + let uninit_callable = MaybeUninit::::uninit(); + // Then move that value into the closure. We assume that the closure now has a in memory layout of Self. + let uninit_closure = move || Self::read(unsafe { &*uninit_callable.as_ptr() }); + + // Check that the size of the closure is the same as the size of Self in case the compiler changed the layout of the closure. + let size_of_closure = std::mem::size_of_val(&uninit_closure); + assert_eq!(size_of_closure, std::mem::size_of::()); + + // Then cast the lifetime of the closure to the lifetime of &self. + fn cast_lifetime<'a, T>(_a: &T, b: &'a T) -> &'a T { + b + } + let reference_to_closure = cast_lifetime( + { + // The real closure that we will never use. + &uninit_closure + }, + // We transmute self into a reference to the closure. This is safe because we know that the closure has the same memory layout as Self so &Closure == &Self. + unsafe { std::mem::transmute(self) }, + ); + + // Cast the closure to a trait object. + reference_to_closure as &Self::Target + } +} diff --git a/packages/signals/src/selector.rs b/packages/signals/src/selector.rs index 346506ff7..556c8437c 100644 --- a/packages/signals/src/selector.rs +++ b/packages/signals/src/selector.rs @@ -21,6 +21,7 @@ use crate::{get_effect_stack, signal::SignalData, CopyValue, Effect, ReadOnlySig /// render! { "{double}" } /// } /// ``` +#[must_use = "Consider using `use_effect` to rerun a callback when dependencies change"] pub fn use_selector( cx: &ScopeState, f: impl FnMut() -> R + 'static, @@ -44,6 +45,7 @@ pub fn use_selector( /// render! { "{double}" } /// } /// ``` +#[must_use = "Consider using `use_effect` to rerun a callback when dependencies change"] pub fn use_selector_with_dependencies( cx: &ScopeState, dependencies: D, @@ -76,19 +78,21 @@ pub fn selector(mut f: impl FnMut() -> R + 'static) -> ReadOnlySig let effect = Effect { source: current_scope_id().expect("in a virtual dom"), callback: CopyValue::invalid(), + effect_stack: get_effect_stack(), }; { - get_effect_stack().effects.borrow_mut().push(effect); + get_effect_stack().effects.write().push(effect); } state.inner.value.set(SignalData { subscribers: Default::default(), effect_subscribers: Default::default(), update_any: schedule_update_any().expect("in a virtual dom"), value: f(), + effect_stack: get_effect_stack(), }); { - get_effect_stack().effects.borrow_mut().pop(); + get_effect_stack().effects.write().pop(); } effect.callback.value.set(Box::new(move || { diff --git a/packages/signals/src/signal.rs b/packages/signals/src/signal.rs index c1ae076d9..1dc4bd36e 100644 --- a/packages/signals/src/signal.rs +++ b/packages/signals/src/signal.rs @@ -1,5 +1,6 @@ use std::{ - cell::{Ref, RefCell, RefMut}, + cell::RefCell, + mem::MaybeUninit, ops::{Deref, DerefMut}, rc::Rc, sync::Arc, @@ -9,8 +10,9 @@ use dioxus_core::{ prelude::{current_scope_id, has_context, provide_context, schedule_update_any}, ScopeId, ScopeState, }; +use generational_box::{GenerationalRef, GenerationalRefMut}; -use crate::{CopyValue, Effect}; +use crate::{get_effect_stack, CopyValue, Effect, EffectStack}; /// Creates a new Signal. Signals are a Copy state management solution with automatic dependency tracking. /// @@ -43,8 +45,19 @@ use crate::{CopyValue, Effect}; /// } /// } /// ``` +#[track_caller] +#[must_use] pub fn use_signal(cx: &ScopeState, f: impl FnOnce() -> T) -> Signal { - *cx.use_hook(|| Signal::new(f())) + #[cfg(debug_assertions)] + let caller = std::panic::Location::caller(); + + *cx.use_hook(|| { + Signal::new_with_caller( + f(), + #[cfg(debug_assertions)] + caller, + ) + }) } #[derive(Clone)] @@ -80,6 +93,7 @@ pub(crate) struct SignalData { pub(crate) subscribers: Rc>>, pub(crate) effect_subscribers: Rc>>, pub(crate) update_any: Arc, + pub(crate) effect_stack: EffectStack, pub(crate) value: T, } @@ -135,6 +149,7 @@ impl<'de, T: serde::Deserialize<'de> + 'static> serde::Deserialize<'de> for Sign impl Signal { /// Creates a new Signal. Signals are a Copy state management solution with automatic dependency tracking. + #[track_caller] pub fn new(value: T) -> Self { Self { inner: CopyValue::new(SignalData { @@ -142,10 +157,31 @@ impl Signal { effect_subscribers: Default::default(), update_any: schedule_update_any().expect("in a virtual dom"), value, + effect_stack: get_effect_stack(), }), } } + /// Creates a new Signal. Signals are a Copy state management solution with automatic dependency tracking. + fn new_with_caller( + value: T, + #[cfg(debug_assertions)] caller: &'static std::panic::Location<'static>, + ) -> Self { + Self { + inner: CopyValue::new_with_caller( + SignalData { + subscribers: Default::default(), + effect_subscribers: Default::default(), + update_any: schedule_update_any().expect("in a virtual dom"), + value, + effect_stack: get_effect_stack(), + }, + #[cfg(debug_assertions)] + caller, + ), + } + } + /// Create a new signal with a custom owner scope. The signal will be dropped when the owner scope is dropped instead of the current scope. pub fn new_in_scope(value: T, owner: ScopeId) -> Self { Self { @@ -155,6 +191,7 @@ impl Signal { effect_subscribers: Default::default(), update_any: schedule_update_any().expect("in a virtual dom"), value, + effect_stack: get_effect_stack(), }, owner, ), @@ -166,11 +203,13 @@ impl Signal { self.inner.origin_scope() } - /// Get the current value of the signal. This will subscribe the current scope to the signal. + /// Get the current value of the signal. This will subscribe the current scope to the signal. If you would like to read the signal without subscribing to it, you can use [`Self::peek`] instead. + /// /// If the signal has been dropped, this will panic. - pub fn read(&self) -> Ref { + #[track_caller] + pub fn read(&self) -> GenerationalRef { let inner = self.inner.read(); - if let Some(effect) = Effect::current() { + if let Some(effect) = inner.effect_stack.current() { let mut effect_subscribers = inner.effect_subscribers.borrow_mut(); if !effect_subscribers.contains(&effect) { effect_subscribers.push(effect); @@ -192,14 +231,24 @@ impl Signal { } } } - Ref::map(inner, |v| &v.value) + GenerationalRef::map(inner, |v| &v.value) + } + + /// Get the current value of the signal. **Unlike read, this will not subscribe the current scope to the signal which can cause parts of your UI to not update.** + /// + /// If the signal has been dropped, this will panic. + pub fn peek(&self) -> GenerationalRef { + let inner = self.inner.read(); + GenerationalRef::map(inner, |v| &v.value) } /// Get a mutable reference to the signal's value. + /// /// If the signal has been dropped, this will panic. - pub fn write(&self) -> Write<'_, T> { + #[track_caller] + pub fn write(&self) -> Write { let inner = self.inner.write(); - let borrow = RefMut::map(inner, |v| &mut v.value); + let borrow = GenerationalRefMut::map(inner, |v| &mut v.value); Write { write: borrow, signal: SignalSubscriberDrop { signal: *self }, @@ -235,12 +284,14 @@ impl Signal { } /// Set the value of the signal. This will trigger an update on all subscribers. + #[track_caller] pub fn set(&self, value: T) { *self.write() = value; } /// Run a closure with a reference to the signal's value. /// If the signal has been dropped, this will panic. + #[track_caller] pub fn with(&self, f: impl FnOnce(&T) -> O) -> O { let write = self.read(); f(&*write) @@ -248,6 +299,7 @@ impl Signal { /// Run a closure with a mutable reference to the signal's value. /// If the signal has been dropped, this will panic. + #[track_caller] pub fn with_mut(&self, f: impl FnOnce(&mut T) -> O) -> O { let mut write = self.write(); f(&mut *write) @@ -257,17 +309,58 @@ impl Signal { impl Signal { /// Get the current value of the signal. This will subscribe the current scope to the signal. /// If the signal has been dropped, this will panic. + #[track_caller] pub fn value(&self) -> T { self.read().clone() } } +impl Signal { + /// Invert the boolean value of the signal. This will trigger an update on all subscribers. + pub fn toggle(&self) { + self.set(!self.value()); + } +} + impl PartialEq for Signal { fn eq(&self, other: &Self) -> bool { self.inner == other.inner } } +impl Deref for Signal { + type Target = dyn Fn() -> GenerationalRef; + + fn deref(&self) -> &Self::Target { + // https://github.com/dtolnay/case-studies/tree/master/callable-types + + // First we create a closure that captures something with the Same in memory layout as Self (MaybeUninit). + let uninit_callable = MaybeUninit::::uninit(); + // Then move that value into the closure. We assume that the closure now has a in memory layout of Self. + let uninit_closure = move || Self::read(unsafe { &*uninit_callable.as_ptr() }); + + // Check that the size of the closure is the same as the size of Self in case the compiler changed the layout of the closure. + let size_of_closure = std::mem::size_of_val(&uninit_closure); + assert_eq!(size_of_closure, std::mem::size_of::()); + + // Then cast the lifetime of the closure to the lifetime of &self. + fn cast_lifetime<'a, T>(_a: &T, b: &'a T) -> &'a T { + b + } + let reference_to_closure = cast_lifetime( + { + // The real closure that we will never use. + &uninit_closure + }, + // We transmute self into a reference to the closure. This is safe because we know that the closure has the same memory layout as Self so &Closure == &Self. + unsafe { std::mem::transmute(self) }, + ); + + // Cast the closure to a trait object. + reference_to_closure as &Self::Target + } +} + struct SignalSubscriberDrop { signal: Signal, } @@ -279,17 +372,17 @@ impl Drop for SignalSubscriberDrop { } /// A mutable reference to a signal's value. -pub struct Write<'a, T: 'static, I: 'static = T> { - write: RefMut<'a, T>, +pub struct Write { + write: GenerationalRefMut, signal: SignalSubscriberDrop, } -impl<'a, T: 'static, I: 'static> Write<'a, T, I> { +impl Write { /// Map the mutable reference to the signal's value to a new type. - pub fn map(myself: Self, f: impl FnOnce(&mut T) -> &mut O) -> Write<'a, O, I> { + pub fn map(myself: Self, f: impl FnOnce(&mut T) -> &mut O) -> Write { let Self { write, signal } = myself; Write { - write: RefMut::map(write, f), + write: GenerationalRefMut::map(write, f), signal, } } @@ -298,14 +391,14 @@ impl<'a, T: 'static, I: 'static> Write<'a, T, I> { pub fn filter_map( myself: Self, f: impl FnOnce(&mut T) -> Option<&mut O>, - ) -> Option> { + ) -> Option> { let Self { write, signal } = myself; - let write = RefMut::filter_map(write, f).ok(); + let write = GenerationalRefMut::filter_map(write, f); write.map(|write| Write { write, signal }) } } -impl<'a, T: 'static> Deref for Write<'a, T> { +impl Deref for Write { type Target = T; fn deref(&self) -> &Self::Target { @@ -313,7 +406,7 @@ impl<'a, T: 'static> Deref for Write<'a, T> { } } -impl DerefMut for Write<'_, T> { +impl DerefMut for Write { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.write } @@ -335,12 +428,23 @@ impl ReadOnlySignal { self.inner.origin_scope() } - /// Get the current value of the signal. This will subscribe the current scope to the signal. - pub fn read(&self) -> Ref { + /// Get the current value of the signal. This will subscribe the current scope to the signal. If you would like to read the signal without subscribing to it, you can use [`Self::peek`] instead. + /// + /// If the signal has been dropped, this will panic. + #[track_caller] + pub fn read(&self) -> GenerationalRef { self.inner.read() } + /// Get the current value of the signal. **Unlike read, this will not subscribe the current scope to the signal which can cause parts of your UI to not update.** + /// + /// If the signal has been dropped, this will panic. + pub fn peek(&self) -> GenerationalRef { + self.inner.peek() + } + /// Run a closure with a reference to the signal's value. + #[track_caller] pub fn with(&self, f: impl FnOnce(&T) -> O) -> O { self.inner.with(f) } @@ -358,3 +462,36 @@ impl PartialEq for ReadOnlySignal { self.inner == other.inner } } + +impl Deref for ReadOnlySignal { + type Target = dyn Fn() -> GenerationalRef; + + fn deref(&self) -> &Self::Target { + // https://github.com/dtolnay/case-studies/tree/master/callable-types + + // First we create a closure that captures something with the Same in memory layout as Self (MaybeUninit). + let uninit_callable = MaybeUninit::::uninit(); + // Then move that value into the closure. We assume that the closure now has a in memory layout of Self. + let uninit_closure = move || Self::read(unsafe { &*uninit_callable.as_ptr() }); + + // Check that the size of the closure is the same as the size of Self in case the compiler changed the layout of the closure. + let size_of_closure = std::mem::size_of_val(&uninit_closure); + assert_eq!(size_of_closure, std::mem::size_of::()); + + // Then cast the lifetime of the closure to the lifetime of &self. + fn cast_lifetime<'a, T>(_a: &T, b: &'a T) -> &'a T { + b + } + let reference_to_closure = cast_lifetime( + { + // The real closure that we will never use. + &uninit_closure + }, + // We transmute self into a reference to the closure. This is safe because we know that the closure has the same memory layout as Self so &Closure == &Self. + unsafe { std::mem::transmute(self) }, + ); + + // Cast the closure to a trait object. + reference_to_closure as &Self::Target + } +} diff --git a/packages/signals/tests/create.rs b/packages/signals/tests/create.rs index 57d8325af..ecda2f376 100644 --- a/packages/signals/tests/create.rs +++ b/packages/signals/tests/create.rs @@ -29,6 +29,30 @@ fn create_signals_global() { } } +#[test] +fn deref_signal() { + let mut dom = VirtualDom::new(|cx| { + render! { + for _ in 0..10 { + Child {} + } + } + }); + + fn Child(cx: Scope) -> Element { + let signal = Signal::new("hello world".to_string()); + + // You can call signals like functions to get a Ref of their value. + assert_eq!(&*signal(), "hello world"); + + render! { + "hello world" + } + } + + let _edits = dom.rebuild().santize(); +} + #[test] fn drop_signals() { let mut dom = VirtualDom::new(|cx| { diff --git a/packages/ssr/Cargo.toml b/packages/ssr/Cargo.toml index f6376452d..71f5b26a0 100644 --- a/packages/ssr/Cargo.toml +++ b/packages/ssr/Cargo.toml @@ -10,6 +10,7 @@ keywords = ["dom", "ui", "gui", "react", "ssr"] [dependencies] dioxus-core = { workspace = true, features = ["serialize"] } +dioxus-html = { workspace = true } askama_escape = "0.10.3" thiserror = "1.0.23" rustc-hash = "1.1.0" @@ -17,6 +18,8 @@ lru = "0.10.0" tracing = { workspace = true } http = "0.2.9" tokio = { version = "1.28", features = ["full"], optional = true } +async-trait = "0.1.58" +serde_json = { version = "1.0" } [dev-dependencies] dioxus = { workspace = true } diff --git a/packages/ssr/src/eval.rs b/packages/ssr/src/eval.rs new file mode 100644 index 000000000..2ab74f4e1 --- /dev/null +++ b/packages/ssr/src/eval.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use dioxus_core::ScopeState; +use dioxus_html::prelude::{EvalError, EvalProvider, Evaluator}; +use std::rc::Rc; + +/// Provides the SSREvalProvider through [`cx.provide_context`]. +pub fn init_eval(cx: &ScopeState) { + let provider: Rc = Rc::new(SSREvalProvider {}); + cx.provide_context(provider); +} + +/// Reprents the ssr-target's provider of evaluators. +pub struct SSREvalProvider; +impl EvalProvider for SSREvalProvider { + fn new_evaluator(&self, _: String) -> Result, EvalError> { + Ok(Rc::new(SSREvaluator) as Rc) + } +} + +/// Represents a ssr-target's JavaScript evaluator. +pub struct SSREvaluator; + +// In ssr rendering we never run or resolve evals. +#[async_trait(?Send)] +impl Evaluator for SSREvaluator { + /// Runs the evaluated JavaScript. + async fn join(&self) -> Result { + std::future::pending::<()>().await; + unreachable!() + } + + /// Sends a message to the evaluated JavaScript. + fn send(&self, _el: serde_json::Value) -> Result<(), EvalError> { + Ok(()) + } + + /// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript. + async fn recv(&self) -> Result { + std::future::pending::<()>().await; + unreachable!() + } +} diff --git a/packages/ssr/src/incremental.rs b/packages/ssr/src/incremental.rs index c3bb25314..34ffa3443 100644 --- a/packages/ssr/src/incremental.rs +++ b/packages/ssr/src/incremental.rs @@ -81,6 +81,7 @@ impl IncrementalRenderer { let mut html_buffer = WriteBuffer { buffer: Vec::new() }; { let mut vdom = VirtualDom::new_with_props(comp, props); + crate::eval::init_eval(vdom.base_scope()); rebuild_with(&mut vdom).await; renderer.render_before_body(&mut *html_buffer)?; diff --git a/packages/ssr/src/lib.rs b/packages/ssr/src/lib.rs index 6794d1f14..fdc0e9333 100644 --- a/packages/ssr/src/lib.rs +++ b/packages/ssr/src/lib.rs @@ -10,6 +10,7 @@ pub mod incremental; #[cfg(feature = "incremental")] mod incremental_cfg; +pub mod eval; pub mod renderer; pub mod template; @@ -42,6 +43,7 @@ pub fn render_lazy(f: LazyNodes<'_, '_>) -> String { }; let mut dom = VirtualDom::new_with_props(lazy_app, props); + crate::eval::init_eval(dom.base_scope()); _ = dom.rebuild(); Renderer::new().render(&dom) diff --git a/packages/web/src/dom.rs b/packages/web/src/dom.rs index 4fa5c9444..f45d6558a 100644 --- a/packages/web/src/dom.rs +++ b/packages/web/src/dom.rs @@ -294,7 +294,7 @@ pub fn virtual_event_from_websys_event(event: web_sys::Event, target: Element) - "select" => Rc::new(SelectionData {}), "touchcancel" | "touchend" | "touchmove" | "touchstart" => Rc::new(TouchData::from(event)), - "scroll" => Rc::new(()), + "scroll" => Rc::new(ScrollData {}), "wheel" => Rc::new(WheelData::from(event)), "animationstart" | "animationend" | "animationiteration" => { Rc::new(AnimationData::from(event)) diff --git a/packages/web/src/hot_reload.rs b/packages/web/src/hot_reload.rs index 6256cc935..e8b4e42e4 100644 --- a/packages/web/src/hot_reload.rs +++ b/packages/web/src/hot_reload.rs @@ -5,7 +5,6 @@ use futures_channel::mpsc::UnboundedReceiver; use dioxus_core::Template; pub(crate) fn init() -> UnboundedReceiver> { - use std::convert::TryInto; use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use web_sys::{MessageEvent, WebSocket}; @@ -31,14 +30,12 @@ pub(crate) fn init() -> UnboundedReceiver> { // change the rsx when new data is received let cl = Closure::wrap(Box::new(move |e: MessageEvent| { if let Ok(text) = e.data().dyn_into::() { - let text: Result = text.try_into(); - if let Ok(string) = text { - let val = serde_json::from_str::(&string).unwrap(); - // leak the value - let val: &'static serde_json::Value = Box::leak(Box::new(val)); - let template: Template<'_> = Template::deserialize(val).unwrap(); - tx.unbounded_send(template).unwrap(); - } + let string: String = text.into(); + let val = serde_json::from_str::(&string).unwrap(); + // leak the value + let val: &'static serde_json::Value = Box::leak(Box::new(val)); + let template: Template<'_> = Template::deserialize(val).unwrap(); + tx.unbounded_send(template).unwrap(); } }) as Box);