Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

xilem_web: Add a await_once view, and a simple example suspense showing it in action. #452

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ members = [
"xilem_web/web_examples/fetch",
"xilem_web/web_examples/todomvc",
"xilem_web/web_examples/mathml_svg",
"xilem_web/web_examples/suspense",
"xilem_web/web_examples/svgtoy",
]

Expand Down
118 changes: 118 additions & 0 deletions xilem_web/src/concurrent/await_once.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright 2024 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0

use std::{future::Future, marker::PhantomData};

use wasm_bindgen::UnwrapThrowExt;
use wasm_bindgen_futures::spawn_local;
use xilem_core::{MessageResult, Mut, NoElement, View, ViewId};

use crate::{DynMessage, OptionalAction, ViewCtx};

/// Await a future returned by `init_future`, `callback` is called with the output of the future. `init_future` will only be invoked once. Use [`await_once`] for construction of this [`View`]
pub struct AwaitOnce<InitFuture, Callback, State, Action> {
init_future: InitFuture,
callback: Callback,
phantom: PhantomData<fn() -> (State, Action)>,
}

/// Await a future returned by `init_future`, `callback` is called with the output of the future. `init_future` will only be invoked once.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a sentence about the fact that a change of &mut AppState has no effect at all and that if it should be so, then async_repeat must be used for it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean a change in AppState has effect (in a rerender)? It just won't have an effect when using interior mutability within e.g. the future. (Which we should probably generally document).

That was actually a reason why I wanted to include it in the init_future so that some kind of is_loading state could be set.

///
/// # Examples
///
/// ```
/// use xilem_web::{core::fork, concurrent::await_once, elements::html::div, interfaces::Element};
///
/// fn app_logic(state: &mut i32) -> impl Element<i32> {
/// fork(
/// div(*state),
/// await_once(
/// |_state: &mut i32| std::future::ready(42),
/// |state: &mut i32, meaning_of_life| *state = meaning_of_life,
/// )
/// )
/// }
/// ```
pub fn await_once<State, Action, FOut, F, InitFuture, OA, Callback>(
init_future: InitFuture,
callback: Callback,
) -> AwaitOnce<InitFuture, Callback, State, Action>
where
State: 'static,
Action: 'static,
FOut: std::fmt::Debug + 'static,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
FOut: std::fmt::Debug + 'static,
FOut: fmt::Debug + 'static,

....and of course use std::fmt; at the top. Not important, just a personal preference 😉

F: Future<Output = FOut> + 'static,
InitFuture: Fn(&mut State) -> F + 'static,
OA: OptionalAction<Action> + 'static,
Callback: Fn(&mut State, FOut) -> OA + 'static,
{
AwaitOnce {
init_future,
callback,
phantom: PhantomData,
}
}

pub struct AwaitOnceState<F> {
future: Option<F>,
}

impl<State, Action, InitFuture, F, FOut, Callback, OA> View<State, Action, ViewCtx, DynMessage>
for AwaitOnce<InitFuture, Callback, State, Action>
where
State: 'static,
Action: 'static,
FOut: std::fmt::Debug + 'static,
F: Future<Output = FOut> + 'static,
InitFuture: Fn(&mut State) -> F + 'static,
OA: OptionalAction<Action> + 'static,
Callback: Fn(&mut State, FOut) -> OA + 'static,
{
type Element = NoElement;

type ViewState = AwaitOnceState<F>;

fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
let thunk = ctx.message_thunk();
// we can't directly push the initial message, as this would be executed directly (not in the next microtick), which in turn means that the already mutably borrowed `App` would be borrowed again.
// So we have to delay this with a spawn_local
spawn_local(async move { thunk.push_message(None::<FOut>) });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can remember, we already have this situation in several use cases. I wonder if it is time to either extend the MessageThunk with e.g. fn send_message_asynchronously, or if there is another common solution?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I've thought about this as well, makes sense. Maybe we want to rename push_message to handle_message (so that it's more clear, that it will be handled immediately, which is probably relevant for events) and add a new method enqueue_message or defer_message with this inside.

(NoElement, AwaitOnceState { future: None })
}

fn rebuild<'el>(
&self,
_prev: &Self,
view_state: &mut Self::ViewState,
ctx: &mut ViewCtx,
(): Mut<'el, Self::Element>,
) -> Mut<'el, Self::Element> {
if let Some(future) = view_state.future.take() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if let Some(future) = view_state.future.take() {
let Some(future) = view_state.future.take() else {
return;
};

let thunk = ctx.message_thunk();
spawn_local(async move { thunk.push_message(Some(future.await)) });
}
}

fn teardown(&self, _: &mut Self::ViewState, _: &mut ViewCtx, _: Mut<'_, Self::Element>) {}

fn message(
&self,
view_state: &mut Self::ViewState,
id_path: &[ViewId],
message: DynMessage,
app_state: &mut State,
) -> MessageResult<Action, DynMessage> {
assert!(id_path.is_empty()); // `debug_assert!` instead? to save some bytes in the release binary?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..yes, I almost always use debug_assert, but mostly in projects where the potential bugs should actually be noticed during development. I don't know enough about the internals of Xilem to judge the likelihood of this.
In any case, it is an advantage to create a panic as early as possible if something is obviously fundamentally wrong.
Since Xilem is still under development, an early bug report from users is probably also very desirable.
In other words: at the moment, it would be even more important to get early feedback on errors than to save bytes 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, my thinkings similar, it's probably better to get bug reports when this is somehow triggered.

match *message.downcast().unwrap_throw() {
Some(future_output) => match (self.callback)(app_state, future_output).action() {
Some(action) => MessageResult::Action(action),
None => MessageResult::Nop,
},
// Initial trigger in build, invoke the init_future and spawn it in `View::rebuild`
None => {
view_state.future = Some((self.init_future)(app_state));
MessageResult::RequestRebuild
}
}
}
}
4 changes: 3 additions & 1 deletion xilem_web/src/concurrent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

//! Async views, allowing concurrent operations, like fetching data from a server

mod memoized_await;
mod await_once;
pub use await_once::{await_once, AwaitOnce};

mod memoized_await;
pub use memoized_await::{memoized_await, MemoizedAwait};
65 changes: 33 additions & 32 deletions xilem_web/web_examples/fetch/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,44 @@

<head>
<link data-trunk rel="rust" data-wasm-opt="z" />
</head>
<style>
img {
max-width: 250px;
height: auto;
}

.error {
border: 1px solid red;
color: red;
background-color: lightpink;
}

.cat-fetch-controls {
margin: auto;
width: 50%;
min-width: 300px;
}

.blink {
animation: blinker 1s infinite;
}

@keyframes blinker {
from {
color: blueviolet;
<title>Fetch | Xilem Web</title>
<style>
img {
max-width: 250px;
height: auto;
}

.error {
border: 1px solid red;
color: red;
background-color: lightpink;
}

.cat-fetch-controls {
margin: auto;
width: 50%;
min-width: 300px;
}

50% {
color: hotpink;
.blink {
animation: blinker 1s infinite;
}

to {
color: blueviolet;
@keyframes blinker {
from {
color: blueviolet;
}

50% {
color: hotpink;
}

to {
color: blueviolet;
}
}
}
</style>
</style>
</head>

<body></body>

Expand Down
16 changes: 16 additions & 0 deletions xilem_web/web_examples/suspense/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "suspense"
version = "0.1.0"
publish = false
license.workspace = true
edition.workspace = true

[lints]
workspace = true

[dependencies]
console_error_panic_hook = "0.1"
wasm-bindgen = "0.2.92"
web-sys = "0.3.69"
xilem_web = { path = "../.." }
gloo-timers = { version = "0.3.0", features = ["futures"] }
28 changes: 28 additions & 0 deletions xilem_web/web_examples/suspense/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>

<head>
<title>Suspense | Xilem Web</title>
<style>
.blink {
animation: blinker 1s infinite;
}

@keyframes blinker {
from {
color: blueviolet;
}

50% {
color: hotpink;
}

to {
color: blueviolet;
}
</style>
</head>

<body></body>

</html>
35 changes: 35 additions & 0 deletions xilem_web/web_examples/suspense/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright 2023 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0

use gloo_timers::future::TimeoutFuture;
use xilem_web::{
concurrent::await_once,
core::{fork, one_of::Either},
document_body,
elements::html::{h1, p},
interfaces::Element,
App,
};

fn app_logic(view_has_resolved: &mut bool) -> impl Element<bool> {
let view = if !*view_has_resolved {
Either::A(p("This will be replaced soon"))
} else {
Either::B(h1("The time has come for fanciness"))
};
fork(
// note how the `Class` view is applied to either the p or the h1 element
view.class(view_has_resolved.then_some("blink")),
await_once(
|_| TimeoutFuture::new(5000),
|view_has_resolved: &mut bool, _| {
*view_has_resolved = true;
},
),
)
}

pub fn main() {
console_error_panic_hook::set_once();
App::new(document_body(), false, app_logic).run();
}