DioxusLabs / DioxusLabs/dioxus
Learning Dioxus (a.k.a. "Death by 1000 papercuts" π ...)
- Dominant language
- Rust
- Stars
- 39.1k
- Forks
- 1.9k
- Avg merge
- 4d 10h
- Merged PRs (30d)
- 4
Description
# Introduction
Hello Dioxus Labs!
I've been learning Dioxus the last few days, and it's been quite the experience.
Thank you so much for all the work you put into this project. It's a *mildly frustrating*, and ***really exciting*** way to build web applications :sweat_smile:
I'm coming to this with hardly any experience with React, Vue, and all the other new magical "Web 7.0" frameworks and methodologies, so I'm truly starting from scratch.
This project has huge potential, so I thought you might appreciate some feedback from someone who is experienced with Rust, but a novice to Web and Wasm.
At this point, I've managed to write the beginnings of a web frontend to my project [Bifrost](https://github.com/chrivers/bifrost), which is a Philips Hue Bridge emulator, that integrates with Zigbee2Mqtt and provides a Hue API, suitable for use by Hue client programs, such as the "Hue" app for smartphones, and other programs that expect a Hue Bridge.
In the following, I'd like to share with you my journey through learning Dioxus, and give my honest impressions about the different parts.
The last section, "Papercuts", is a list of bugs, minor errors, as well as concepts and situations that I found/find confusing.
I'd be more than happy to turn any number of these into individual bug reports, but I thought I'd start by getting the ball rolling.
Let me know which "papercuts" (if any) you'd like me to create issues for.
# "First Pass"
After reading almost the entire Dioxus guide, I got the impression that the "fullstack" option was the right one for me.
After all, I have an existing Axum-based project, that serves http(s) requests, and uses Axum extractors to access server state in the response handlers.
All is well, then? Add `dioxus`, enable some features, and away we go?
Well, not quite. This turned out to be brutally difficult. Using Dioxus in this way is invasive. It feels like Dioxus wants to control my entire project, which is a workspace consisting of ~7 crates.
Having to set (almost) *every single* crate to `optional = true`, and adding features, to basically turn one project into different shapes depending on compilation mode, is not enjoyable.
Personally, I think this mode looks easy for small, simple projects (it certainly looks easy in the guide), but for a project with ~60 depencies and 7 crates, it's not quite as smooth.
Also, it was really quite difficult to figure out how to integrate Dioxus fullstack routing into a custom Axum project. For the curious, this was my hack-in-progress that sort of worked:
```rust
let dsvc = {
// register server functions
let config = ServeConfigBuilder::new()
.context_providers(Arc::new(vec![Box::new(move || Box::new(ac.clone()))]));
let router = axum::Router::new()
.serve_dioxus_application(config, bifrost::web2::App)
/* .with_state(appstate.clone()) */
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request| {
info_span!(
"http",
method = ?request.method(),
uri = ?request.uri(),
status = tracing::field::Empty,
/* latency = tracing::field::Empty, */
)
})
.on_response(trace_layer_on_response),
);
// start server
let socket_addr = dioxus_cli_config::fullstack_address_or_localhost();
if let IpAddr::V4(ip) = socket_addr.ip() {
let mut port = dbg!(socket_addr.port());
if port == 8080 {
port += 1;
}
HttpServer::http(ip, port, router.into_make_service())
} else {
panic!();
}
};
mgr.register_service("dioxus", dsvc).await?;
```
In the end, I could not find a reasonable, manageble way forward in the "fullstack" direction, so I scrapped that attempt, and spent some time on research.
After some `research` && `thinking about it`, I decided to go for a more traditional model; A static web application, that uses api calls and a websocket connection, to communicate with the server.
# Second time's the charm: Compile to html + wasm
And oh boy, did this turn out to be a completely different experience.
I'm not saying it doesn't have some sharp corners, but right from the beginning, the whole process was *way* smoother.
I did get stuck a good number of times, but each time I was able to solve the problem in the end, and this style only requires minimal, non-invasive changes to my existing server project.
Tonight, I've successfully served the static content generated by `dx bundle` using a `tower_http::service::DirServe` service, effectively proof-of-concepting the viability of distributing a version of Bifrost that finally has a web frontend!
And I have to say, the experience of writing a fully reactive web app in pure rust, and being able to re-use 3 crates (through minor modifications), is *absolutely mindblowing*.
Changing the state of an entity on my phone, and *immediately* seeing the change reflected in my browser, no matter how minor a detail, or how complicated the change, is really a sight to behold.
So thank you, Dioxus Labs - you have made something really special here :partying_face:
Please, keep up the good work! I'm excited for the future of Dioxus!
# Papercuts
So... this brings us to the "papercuts" section. :sweat_smile:
While working on this project, I ran into quite a few things that were either confusing, possibly outdated, or maybe just slightly wrong.
After that happened a few times, I decided to start taking notes, which feels like a good decision now.
I hope the following descriptions are clear and meaningful. Otherwise, please don't hesitate to ask, I'd be happy to elaborate.
And now, in really no particular order, let's take a look at the papercuts. :wink:
## Async closure for "onclick": spawns into the aether
When interfacing with a rest api, and targeting web, there's really no other choice than using an `async` http client. This means lots of `async` integration in event handlers.
In general, this can work:
```rust
rsx! {
div {
onclick: move |_| {
async move {
// ...
}
}
}
}
```
However, it's not that ergonomic to use:
### 1. The "magic" `async` support works by returning a future from the event handler
This means there's no way to use an `async` block in the middle of the event handler. The entire "bottom half" (or the whole) of the event handler must be `async`.
### 2. There's (seemingly?) no way to .await the async block
This makes it extra difficult to make things happen in a certain order.
For example, I tried to make a button component that displays a spinner while the `.onclick` is running. Conceptually, something like this:
```rust
#[component]
pub fn SpinnerButton(onclick: EventHandler, running: Signal) -> Element {
rsx! {
button {
onclick: move |evt| {
running.set(true);
onclick.call(evt);
running.set(false);
}
}
}
}
```
This seems reasonable, no?
Well, if `onclick` is given as an `async move` block, it's wrapped in something that causes it to be spawned, but not awaited in a blocking-like way. The result is that `.call()` exits immediately, and so the `SpinnerButton` concept appears broken.
This is pretty surprising, and I haven't been able to find a workaround of any kind.
## Closure with signal is, surprisingly, illegal
So this got me thinking. I could provide a signal to the given event handler, and have *it* update the signal. This would at least provide some of the ergonomics:
```rust
onclick: move |(_, mut updating): (_, Signal)| {
let cli = cli.clone();
async move {
updating.set(true);
cli.delay().await?;
updating.set(false);
Ok(())
}
}
```
Seems reasonable, no?
It turns out, this is COMPLETELY VERBOTEN. Something about the internal way `Signal`s are allocated makes this an invalid way to use and/or handle them.
Curiously, it actually worked fine, but every time someone clicks the button, you get almost a full page of very helpful, but also scary-looking error in the log output.
I don't have any leads on this, either.
## `to_owned!` macro:
A mysterious `to_owned!()` macro is mentioned here:
> https://dioxuslabs.com/learn/0.6/reference/use_coroutine/
But it's never actually shown anywhere. Is this a leftover from an earlier version, or is there a magic macro I could be using for something, hiding in a crate somewhere?
## Spread is not documented
The concept of "spreading" properties is extremely useful, and sorely needed for any kind of abstraction work.
Sadly, it's never even mentioned in the guide.
Also, there are a few more problems with it:
- https://github.com/DioxusLabs/dioxus/issues/3938
- https://github.com/DioxusLabs/dioxus/issues/3717
- https://github.com/DioxusLabs/dioxus/issues/1870
Thankfully, @ealmloff has been working on excellent improvements to this. For that reason, I use dioxus like so:
```toml
# We need something newer than 0.6.3 (newest published at this time),
# that includes fixes to component "prop spreading" ("..foo" syntax).
[patch.crates-io]
dioxus = { git = "https://github.com/DioxusLabs/dioxus.git", rev = "b6243d3" }
```
This pulls in not-yet published fixes to spreading, which are hard to live without. Thanks, @ealmloff! I hope you'll keep up the work in this area. It's incredibly useful, and feels much needed.
## Spread doesn't work with events
Sadly, for reasons that I don't understand, "prop spreading" does *not* work with event handlers.
This makes it incredibly unwieldy to support "wrapped" components in any shape or form. All potentially useful events must be manually wrapped in a cumbersome way:
```rust
#[derive(Props, PartialEq, Debug, Clone)]
pub struct ChildProps {
// ...
#[props(optional, default = None)]
pub onclick: Option>>,
// ...
}
#[component]
pub fn Parent(props: ParentProps) -> Element {
rsx! {
Child {
onclick: move|e| {
if let Some(onclick) = props.onclick {
onclick.call(e);
}
}
}
}
}
```
All this code, just to support *one* potential event. Clearly, that's not a solid solution in the long term.
## Spread doesn't work the same as for rsx (class + class -> error)
This is just surprising, and counter-intuitive. Let's say we're using some tailwind classes:
```rust
// this works
rsx! {
div {
// layout
class: "min-w-100 max-w-180 min-h-screen",
// text
class: "color-red-100 text-mono"
}
}
```
This works fine. The two `class` properties are merged correctly into one `class` html property. Now, let's try using that in a component:
```rust
// this FAILS
rsx! {
WrappedDiv {
// layout
class: "min-w-100 max-w-180 min-h-screen",
// text
class: "color-red-100 text-mono" /// <-- compile error here
}
}
```
## Unrealistic examples
This is more of a stylistic thing, but the examples are almost all simplified, to the point of being quite unrealistic.
Of course, it's a delicate balance to write an easy, not-too-difficult tutorial, but it means bumping into problems you don't yet have any idea how to solve, as soon as you try to venture into slightly-realistic territory.
For example:
- Hardcoded URLs in api requests. Solving this requires some kind of resource sharing.
- Very little mention of how to realistically integrate with an API server
- Missing example of how to use websockets
I know no one has infinite resources, but those points would have really helped me learn Dioxus faster.
Maybe they can be included at some point in the future?
## No hooks in event handlers(!)
This one is annoying.
In my application, I'm using `use_context_provider()` to make several kinds of resources available. It would be perfectly logical to do something like this:
```rust
// DON'T DO THIS: it's bad, mmkay?
rsx! {
div {
onwhatever: move |_| {
let client = use_context::();
client.do_whatever();
}
}
}
```
I did this, and it worked great. It was easy to write, and reasonable easy to read.
However, I've accidentally committed a grave offense.
This example uses a hook inside a closure, which is forbidden by the Hook Police :sweat_smile:.
I don't know if I just got lucky, or if the error message is overly cautious, but this is not allowed - `dx check` says so.
## Hooks are made entirely of sharp edges
I'll admit, I wrote this headline at peak frustration :smile:
But there is a grain of truth in it, I think.
Hooks have some very strict, very non-obvious rules. Instead of causing compile-time problems, they cause run-time explosions.
Honestly, I wouldn't mind having to type a little bit more, or have some things be a little bit more verbose, to remove a few sharp edges from hooks.
I don't really understand the inner workings of hooks, but if the issue is needing a unique id per invocation site, wouldn't some kind of macro with location hashing be a hypothetical option?
That would allow using hooks out of order, from closures, etc. As far as I can tell, it would remove most of the "Rules of hooks"?
## Spreading fails with incomprehensible error:
This is before @ealmloff's fixes, but still the case with stable version 0.6.3:
```rust
#[component]
fn SpinnerButton(
onclick: EventHandler,
updating: Signal,
children: Element,
#[props(extends = button, extends = GlobalAttributes)]
attributes: Vec,
) -> Element {
rsx! {
Button {
onclick: move |evt| onclick.call(evt),
..attributes,
if updating() {
Spinner {}
} else {
{children}
}
}
}
}
```
```
18:17:32 [dev] Build failed: Other(Cargo build failed, signaled by the compiler. Toggle tracing mode (press `t`) for more information.)
18:17:32 [cargo] error[E0609]: no field `onclick` on type `Vec`
--> src/main.rs:464:13
|
464 | onclick: move |evt| onclick.call(evt),
| ^^^^^^^ unknown field
18:17:32 [cargo] error[E0609]: no field `children` on type `Vec`
--> src/main.rs:462:5
|
462 | / rsx! {
463 | | Button {
464 | | onclick: move |evt| onclick.call(evt),
465 | | ..attributes,
... |
473 | | }
| |_____^ unknown field
|
= note: this error originates in the macro `rsx` (in Nightly builds, run with -Z macro-backtrace for more info)
18:17:32 [cargo] error[E0599]: no method named `into_vcomponent` found for struct `Vec` in the current scope
--> src/main.rs:462:5
|
462 | rsx! {
| _____^
463 | | Button {
464 | | onclick: move |evt| onclick.call(evt),
465 | | ..attributes,
... |
473 | | }
| |_____^ method not found in `Vec`
|
= note: this error originates in the macro `rsx` (in Nightly builds, run with -Z macro-backtrace for more info)
```
## dx serve randomly fails to detect rebuild is needed
I think I've identified a specific papercut with `dx serve`:
When adding a new file to a project, that file is not watched for changes, leading to on-again, off-again reactions from `dx serve`.
However, I have also seen a build error cause `dx serve` to not pick up later changes to the same file, even when those changes would cause the compile to succeed. After pressing `r` to rebuild, things seem to be back on track.
## `dx check` checks random files not in project (and no exclude option)
In my projects, I often make a `/notes` directory, and put bit and pieces that I don't want in git, in there. Stuff like checkouts of other projects, scrapped pieces of code, etc. In my case, I had a git checkout of Dioxus, for reference.
However, `dx check` has no chill. It will dig through subfolders like an orangutan with Mad Cow Disease.
Hilariously, this means I get several screens worth of warnings from `dx check`, that "my" code (in `notes/dioxus/...`) is doing bad things with hooks. This seems to be from test cases in dioxus.
So now I'm stuck with ignoring ~250 lines of junk output, or moving stuff around, just because `dx check` runs wild.
I think this is a bug? I mean, `dx check --help` says
> Check ***the project*** for any issues
But this is not just the project, this seems to be all rust files in all subdirs?
It would be nice with some kind of fix for this.
## Ergonomics of context_provider hooks
Again, hooks have sharp edges. I accidentally did this:
```rust
let slist = use_context_provider(|| use_signal(ServiceList::default));
let config = use_context_provider(|| use_signal(|| Err(BifrostError::ServerError("foo".to_string()))));
let toast = use_context_provider(|| use_signal(ToastMaster::new));
let hue = use_context_provider(|| use_signal(BTreeMap::new));
```
This happens to work, but is totally against the "Rules of Hooks". So I made these wrappers:
```rust
fn use_context_signal_provider(f: impl FnOnce() -> T) -> Signal {
let inner = use_signal(f);
use_context_provider(move || inner)
}
fn use_context_signal() -> Signal {
use_context::>()
}
```
This makes working with context-provided signals more ergonomic:
```rust
// near the root
let slist = use_context_signal_provider(ServiceList::default);
// where needed
let slist = use_context_signal::();
```
I *guess* it's fine to do it this way?
I'm still not entirely convinced this is not subtly against how hooks and/or signals are allowed to be used, but it seems to work so far.
Addendum: I just found an example here: https://github.com/DioxusLabs/dioxus/tree/main/packages/hooks
```rust
use_context_provider(|| Signal::new(0));
use_context::>();
```
So it does seem to be okay, even though I'm not at all sure *why* it's okay to call `Signal::new()` inside the closure for `use_context_provider`. This strictly seems to be against the "Rules of Hooks", unless `Signal::new()` is magically different from `use_signal()`?
In that case, isn't `use_signal()` just an inferior version of `Signal::new()`, with a bunch of footguns duct taped to it?
Honestly, this is probably the most confusing aspect so far.
## Broken link
In the guide:
https://dioxuslabs.com/learn/0.6/guides/web/
The link to "PWA-example" is broken.
## `dx bundle` ignores --profile
The argument `--profile` is accepted, but totally ignored; `dx bundle` is easy-going like that.. :smile:
```
$ dx bundle --server-profile web-release --verbose
0. 0s INFO dx::cli::bundle: Bundling project...
0. 0s DEBUG dx::dioxus_crate: Loading crate
0.234s DEBUG dx::dioxus_crate: Found package NodeIndex(11)
0.234s DEBUG dx::dioxus_crate: Could not find explicit feature for platform server, passing `fallback` instead
0.235s INFO dx::cli::bundle: Building app...
0.235s DEBUG dx::build::verify: Verifying tooling...
0.267s DEBUG dx::build::request: Running build command...
0.269s DEBUG dx::build::request: Building app...
0.270s DEBUG dx::build::request: Initialized Root dir: "/release/web/public"
0.272s DEBUG dx::build::request: Initialized Exe dir: "/release/web/public/wasm"
0.272s DEBUG dx::build::request: Initialized Asset dir: "/release/web/public/assets"
0.272s DEBUG dx::build::request: Executing cargo...
0.273s DEBUG dx::build::request: cargo args: ["--profile", "release", "--target", "wasm32-unknown-unknown", "--verbose", "--features", "web", "--bin", "bifrost-frontend"] dx_src=build
0.273s DEBUG dx::build::request: Building server...
0.586s DEBUG dx::build::request: cargo args: ["--profile", "release", "--target", "wasm32-unknown-unknown", "--verbose", "--features", "web", "--bin", "bifrost-frontend"] dx_src=build
0.902s INFO dx::build::builder: Compiling [ 1/272]: build-script-build
0.902s INFO dx::build::builder: Compiling [ 2/272]: unicode_ident
```
## `dx bundle` defaults to "release"
It's mentioned that "release" is not the default profile:
```
-r, --release Build in release mode [default: false]
```
However `--release` is always used:
```
dx bundle --verbose
0. 0s INFO dx::cli::bundle: Bundling project...
0. 1s DEBUG dx::dioxus_crate: Loading crate
0.234s DEBUG dx::dioxus_crate: Found package NodeIndex(11)
0.234s DEBUG dx::dioxus_crate: Could not find explicit feature for platform server, passing `fallback` instead
0.234s INFO dx::cli::bundle: Building app...
0.235s DEBUG dx::build::verify: Verifying tooling...
0.269s DEBUG dx::build::request: Running build command...
0.271s DEBUG dx::build::request: Building app...
0.273s DEBUG dx::build::request: Initialized Root dir: "/release/web/public"
0.273s DEBUG dx::build::request: Initialized Exe dir: "/release/web/public/wasm"
0.273s DEBUG dx::build::request: Initialized Asset dir: "/release/web/public/assets"
0.276s DEBUG dx::build::request: Executing cargo...
0.276s DEBUG dx::build::request: cargo args: ["--profile", "release", "--target", "wasm32-unknown-unknown", "--verbose", "--features", "web", "--bin", "bifrost-frontend"] dx_src=build
0.276s DEBUG dx::build::request: Building server...
0.611s DEBUG dx::build::request: cargo args: ["--profile", "release", "--target", "wasm32-unknown-unknown", "--verbose", "--features", "web", "--bin", "bifrost-frontend"] dx_src=build
0.923s INFO dx::build::builder: Compiling [ 1/272]: build-script-build
0.923s INFO dx::build::builder: Compiling [ 2/272]: unicode_ident
```
## dx serve stalls
The "Bundle" step often takes quite a while, and even with trace mode, there's no indication what is taking so long.
```
Bundle: ββββββββββββββββββββββββββ π
For recompilation, the "bundle" time is often ***~3-4 times*** as long as the compilation time. Is this expected behavior? Anything end-users can do to shorten this?
## `ErrorBoundary` seems to be completely broken, part 1
Let's leave with `ErrorBoundary`.
As far as I can tell, they are 100%, completely and utterly broken... I hope I'm wrong :sweat_smile:
First example - this one might just be me misunderstanding how error boundaries are supposed to work. From memory:
```rust
pub fn Foo() -> Element {
rsx! {
ErrorBoundary {
handle_error: |_error| {
rsx! {
"Bad stuff happened"
}
},
div {
onclick: move |_| {
this_fails()?;
Ok(()) // <-- never reached
}
}
}
}
}
```
Here, I would expect the `ErrorBoundary` to activate, and catch the error from the event handler.
In my experience, this *never* happens. I have to catch the error one layer up:
```rust
pub fn Foo() -> Element {
rsx! {
div {
onclick: move |_| {
this_fails()?;
Ok(()) // <-- never reached
}
}
}
}
pub fn Bar() -> Element {
rsx! {
ErrorBoundary {
handle_error: |_error| {
rsx! {
"Bad stuff happened"
}
},
Foo {}
}
}
}
```
This "works", in that the first error that happens will permanently activate the error boundary. From now on `"Bad stuff happened"` is displayed.
This difference doesn't *feel* right. Is it expected behavior?
## `ErrorBoundary` seems to be completely broken, part 2
So, naturally, when an error is triggered, we'd want to be able to clear the error again. At least, that seems intuitive to me. That way, the `ErrorBoundary` would be conceptually like `try-except`, in languages with that kind of feature.
So, I tried this:
```rust
pub fn Bar() -> Element {
rsx! {
ErrorBoundary {
handle_error: |event: ErrorContext| {
event.clear_errors();
rsx! {
"Nope"
}
},
ComponentThatFails { }
}
}
}
```
But that is *decidedly* not good:
```
21:30:01 [web] panicked at /home/chrivers/.cargo/git/checkouts/dioxus-d8abd2ecf6e8b5b4/b6243d3/packages/core/src/error_boundary.rs:270:21:
already borrowed: BorrowMutError
Stack:
...
```
So it seems something is already borrowing the error context, when I want to clear it?
I simply have no idea how this is supposed to work, and I can't figure it out from the documentation, or the source code.
# Conclusion
If you're still reading, thank you for taking the time!
I realize this might be better suited as ~10 different issues, and I'd be happy to split it up into individual pieces. I just wanted to get your first impression, well, first :smile:
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.