xilem_web: Add a `MemoizedAwait` view (#448)

This is the `rerun_on_change` view described in #440, I named it
`MemoizedAwait` as I think that fits its functionality.

It got also additional features `debounce_ms` (default `0`) and
`reset_debounce_on_update` (default `true`) as I think that's quite
useful, in case a lot of updates are happening.

When `reset_debounce_on_update == false`, `debounce` is more a throttle
than an actual debounce.

This also adds a more heavily modified version of the example added in
#427, also showing `OneOf` in xilem_web (which didn't have an example
yet).

This *could* be considered as alternative to #440 (at the very least the
example) but those approaches could also well live next to each other.

---------

Co-authored-by: Markus Kohlhase <markus.kohlhase@slowtec.de>
This commit is contained in:
Philipp Mildenberger 2024-07-26 18:11:34 +02:00 committed by GitHub
parent d70076262d
commit 981fcc4b5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 616 additions and 2 deletions

56
Cargo.lock generated
View File

@ -1062,6 +1062,20 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fetch"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"console_log",
"gloo-net",
"log",
"serde",
"wasm-bindgen",
"web-sys",
"xilem_web",
]
[[package]]
name = "flate2"
version = "1.0.30"
@ -1291,6 +1305,36 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "gloo-net"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173"
dependencies = [
"gloo-utils",
"http",
"js-sys",
"serde",
"serde_json",
"thiserror",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "glow"
version = "0.13.1"
@ -1423,6 +1467,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "http"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "icu_collections"
version = "1.5.0"
@ -4069,6 +4124,7 @@ version = "0.1.0"
dependencies = [
"peniko",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"xilem_core",
]

View File

@ -9,6 +9,7 @@ members = [
"xilem_web/web_examples/counter",
"xilem_web/web_examples/counter_custom_element",
"xilem_web/web_examples/elm",
"xilem_web/web_examples/fetch",
"xilem_web/web_examples/todomvc",
"xilem_web/web_examples/mathml_svg",
"xilem_web/web_examples/svgtoy",

View File

@ -23,6 +23,7 @@ workspace = true
xilem_core = { workspace = true, features = ["kurbo"] }
peniko.workspace = true
wasm-bindgen = "0.2.92"
wasm-bindgen-futures = "0.4.42"
[dependencies.web-sys]
version = "0.3.69"

View File

@ -9,8 +9,11 @@ type CowStr = std::borrow::Cow<'static, str>;
#[derive(PartialEq, Clone, Debug, PartialOrd)]
pub enum AttributeValue {
True, // for the boolean true, this serializes to an empty string (e.g. for <input checked>)
I16(i16),
U16(u16),
I32(i32),
U32(u32),
Usize(usize),
F32(f32),
F64(f64),
String(CowStr),
@ -20,8 +23,11 @@ impl AttributeValue {
pub fn serialize(&self) -> CowStr {
match self {
AttributeValue::True => "".into(), // empty string is equivalent to a true set attribute
AttributeValue::I16(n) => n.to_string().into(),
AttributeValue::U16(n) => n.to_string().into(),
AttributeValue::I32(n) => n.to_string().into(),
AttributeValue::U32(n) => n.to_string().into(),
AttributeValue::Usize(n) => n.to_string().into(),
AttributeValue::F32(n) => n.to_string().into(),
AttributeValue::F64(n) => n.to_string().into(),
AttributeValue::String(s) => s.clone(),
@ -56,9 +62,15 @@ impl IntoAttributeValue for AttributeValue {
}
}
impl IntoAttributeValue for u32 {
impl IntoAttributeValue for i16 {
fn into_attr_value(self) -> Option<AttributeValue> {
Some(AttributeValue::U32(self))
Some(AttributeValue::I16(self))
}
}
impl IntoAttributeValue for u16 {
fn into_attr_value(self) -> Option<AttributeValue> {
Some(AttributeValue::U16(self))
}
}
@ -68,6 +80,18 @@ impl IntoAttributeValue for i32 {
}
}
impl IntoAttributeValue for u32 {
fn into_attr_value(self) -> Option<AttributeValue> {
Some(AttributeValue::U32(self))
}
}
impl IntoAttributeValue for usize {
fn into_attr_value(self) -> Option<AttributeValue> {
Some(AttributeValue::Usize(self))
}
}
impl IntoAttributeValue for f32 {
fn into_attr_value(self) -> Option<AttributeValue> {
Some(AttributeValue::F32(self))

View File

@ -0,0 +1,255 @@
// Copyright 2024 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0
use std::{future::Future, marker::PhantomData};
use wasm_bindgen::{closure::Closure, JsCast, UnwrapThrowExt};
use wasm_bindgen_futures::spawn_local;
use xilem_core::{MessageResult, Mut, NoElement, View, ViewId, ViewPathTracker};
use crate::{DynMessage, OptionalAction, ViewCtx};
/// Await a future returned by `init_future` invoked with the argument `data`, `callback` is called with the output of the future. `init_future` will be invoked again, when `data` changes. Use [`memoized_await`] for construction of this [`View`]
pub struct MemoizedAwait<State, Action, OA, InitFuture, Data, Callback, F, FOut> {
init_future: InitFuture,
data: Data,
callback: Callback,
debounce_ms: usize,
reset_debounce_on_update: bool,
#[allow(clippy::type_complexity)]
phantom: PhantomData<fn() -> (State, Action, OA, F, FOut)>,
}
impl<State, Action, OA, InitFuture, Data, Callback, F, FOut>
MemoizedAwait<State, Action, OA, InitFuture, Data, Callback, F, FOut>
where
FOut: std::fmt::Debug + 'static,
F: Future<Output = FOut> + 'static,
InitFuture: Fn(&Data) -> F,
{
/// Debounce the `init_future` function, when `data` updates,
/// when `reset_debounce_on_update == false` then this throttles updates each `milliseconds`
///
/// The default for this is `0`
pub fn debounce_ms(mut self, milliseconds: usize) -> Self {
self.debounce_ms = milliseconds;
self
}
/// When `reset` is `true`, everytime `data` updates, the debounce timeout is cleared until `init_future` is invoked.
/// This is only effective when `debounce > 0`
///
/// The default for this is `true`
pub fn reset_debounce_on_update(mut self, reset: bool) -> Self {
self.reset_debounce_on_update = reset;
self
}
fn init_future(&self, ctx: &mut ViewCtx, generation: u64) {
ctx.with_id(ViewId::new(generation), |ctx| {
let thunk = ctx.message_thunk();
let future = (self.init_future)(&self.data);
spawn_local(async move {
thunk.push_message(MemoizedAwaitMessage::<FOut>::Output(future.await));
});
});
}
}
/// Await a future returned by `init_future` invoked with the argument `data`, `callback` is called with the output of the resolved future. `init_future` will be invoked again, when `data` changes.
///
/// The update behavior can be controlled, by [`debounce`](`MemoizedAwait::debounce`) and [`reset_debounce_on_update`](`MemoizedAwait::reset_debounce_on_update`)
///
/// # Examples
///
/// ```
/// use xilem_web::{core::fork, concurrent::memoized_await, elements::html::div, interfaces::Element};
///
/// fn app_logic(state: &mut i32) -> impl Element<i32> {
/// fork(
/// div(*state),
/// memoized_await(
/// 10,
/// |count| std::future::ready(*count),
/// |state, output| *state = output,
/// )
/// )
/// }
/// ```
pub fn memoized_await<State, Action, OA, InitFuture, Data, Callback, F, FOut>(
data: Data,
init_future: InitFuture,
callback: Callback,
) -> MemoizedAwait<State, Action, OA, InitFuture, Data, Callback, F, FOut>
where
State: 'static,
Action: 'static,
Data: PartialEq + 'static,
FOut: std::fmt::Debug + 'static,
F: Future<Output = FOut> + 'static,
InitFuture: Fn(&Data) -> F + 'static,
OA: OptionalAction<Action> + 'static,
Callback: Fn(&mut State, FOut) -> OA + 'static,
{
MemoizedAwait {
init_future,
data,
callback,
debounce_ms: 0,
reset_debounce_on_update: true,
phantom: PhantomData,
}
}
#[derive(Default)]
pub struct MemoizedAwaitState {
generation: u64,
schedule_update: bool,
// Closures are retained so they can be called by environment
schedule_update_fn: Option<Closure<dyn FnMut()>>,
schedule_update_timeout_handle: Option<i32>,
update: bool,
}
impl MemoizedAwaitState {
fn clear_update_timeout(&mut self) {
if let Some(handle) = self.schedule_update_timeout_handle {
web_sys::window()
.unwrap_throw()
.clear_timeout_with_handle(handle);
}
self.schedule_update_timeout_handle = None;
self.schedule_update_fn = None;
}
fn reset_debounce_timeout_and_schedule_update<FOut: std::fmt::Debug + 'static>(
&mut self,
ctx: &mut ViewCtx,
debounce_duration: usize,
) {
ctx.with_id(ViewId::new(self.generation), |ctx| {
self.clear_update_timeout();
let thunk = ctx.message_thunk();
let schedule_update_fn = Closure::new(move || {
thunk.push_message(MemoizedAwaitMessage::<FOut>::ScheduleUpdate);
});
let handle = web_sys::window()
.unwrap_throw()
.set_timeout_with_callback_and_timeout_and_arguments_0(
schedule_update_fn.as_ref().unchecked_ref(),
debounce_duration.try_into().unwrap_throw(),
)
.unwrap_throw();
self.schedule_update_fn = Some(schedule_update_fn);
self.schedule_update_timeout_handle = Some(handle);
self.schedule_update = true;
});
}
}
#[derive(Debug)]
enum MemoizedAwaitMessage<Output: std::fmt::Debug> {
Output(Output),
ScheduleUpdate,
}
impl<State, Action, InitFuture, F, FOut, Data, CB, OA> View<State, Action, ViewCtx, DynMessage>
for MemoizedAwait<State, Action, OA, InitFuture, Data, CB, F, FOut>
where
State: 'static,
Action: 'static,
OA: OptionalAction<Action> + 'static,
InitFuture: Fn(&Data) -> F + 'static,
FOut: std::fmt::Debug + 'static,
Data: PartialEq + 'static,
F: Future<Output = FOut> + 'static,
CB: Fn(&mut State, FOut) -> OA + 'static,
{
type Element = NoElement;
type ViewState = MemoizedAwaitState;
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
let mut state = MemoizedAwaitState::default();
if self.debounce_ms > 0 {
state.reset_debounce_timeout_and_schedule_update::<FOut>(ctx, self.debounce_ms);
} else {
self.init_future(ctx, state.generation);
}
(NoElement, state)
}
fn rebuild<'el>(
&self,
prev: &Self,
view_state: &mut Self::ViewState,
ctx: &mut ViewCtx,
(): Mut<'el, Self::Element>,
) -> Mut<'el, Self::Element> {
let debounce_has_changed_and_update_is_scheduled = view_state.schedule_update
&& (prev.reset_debounce_on_update != self.reset_debounce_on_update
|| prev.debounce_ms != self.debounce_ms);
if debounce_has_changed_and_update_is_scheduled {
if self.debounce_ms == 0 {
if view_state.schedule_update_timeout_handle.is_some() {
view_state.clear_update_timeout();
view_state.schedule_update = false;
view_state.update = true;
}
} else {
view_state
.reset_debounce_timeout_and_schedule_update::<FOut>(ctx, self.debounce_ms);
return; // avoid update below, as it's already scheduled
}
}
if view_state.update
|| (prev.data != self.data
&& (!view_state.schedule_update || self.reset_debounce_on_update))
{
if !view_state.update && self.debounce_ms > 0 {
view_state
.reset_debounce_timeout_and_schedule_update::<FOut>(ctx, self.debounce_ms);
} else {
// no debounce
view_state.generation += 1;
view_state.update = false;
self.init_future(ctx, view_state.generation);
}
}
}
fn teardown(&self, state: &mut Self::ViewState, _: &mut ViewCtx, (): Mut<'_, Self::Element>) {
state.clear_update_timeout();
}
fn message(
&self,
view_state: &mut Self::ViewState,
id_path: &[ViewId],
message: DynMessage,
app_state: &mut State,
) -> MessageResult<Action, DynMessage> {
assert_eq!(id_path.len(), 1);
if id_path[0].routing_id() == view_state.generation {
match *message.downcast().unwrap_throw() {
MemoizedAwaitMessage::Output(future_output) => {
match (self.callback)(app_state, future_output).action() {
Some(action) => MessageResult::Action(action),
None => MessageResult::Nop,
}
}
MemoizedAwaitMessage::ScheduleUpdate => {
view_state.update = true;
view_state.schedule_update = false;
MessageResult::RequestRebuild
}
}
} else {
MessageResult::Stale(message)
}
}
}

View File

@ -0,0 +1,8 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
//! Async views, allowing concurrent operations, like fetching data from a server
mod memoized_await;
pub use memoized_await::{memoized_await, MemoizedAwait};

View File

@ -43,6 +43,7 @@ mod text;
mod vec_splice;
mod vecmap;
pub mod concurrent;
pub mod elements;
pub mod interfaces;
pub mod svg;

View File

@ -0,0 +1,19 @@
[package]
name = "fetch"
version = "0.1.0"
publish = false
license.workspace = true
edition.workspace = true
[lints]
workspace = true
[dependencies]
console_error_panic_hook = "0.1"
console_log = "1"
gloo-net = { version = "0.5.0", default-features = false, features = ["http", "json", "serde"] }
log = "0.4"
serde = { version = "1", features = ["derive"] }
web-sys = { version = "0.3.69", features = ["Event", "HtmlInputElement"] }
wasm-bindgen = "0.2.92"
xilem_web = { path = "../.." }

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<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;
}
50% {
color: hotpink;
}
to {
color: blueviolet;
}
}
</style>
<body></body>
</html>

View File

@ -0,0 +1,203 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::{JsCast, UnwrapThrowExt};
use xilem_web::{
concurrent::memoized_await,
core::{fork, one_of::Either},
document_body,
elements::html::*,
interfaces::{Element, HtmlDivElement, HtmlImageElement, HtmlLabelElement},
App,
};
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
const TOO_MANY_CATS: usize = 8;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cat {
pub url: String,
pub width: u16,
pub height: u16,
}
struct AppState {
cats_to_fetch: usize,
cats_are_being_fetched: bool,
cats: Vec<Cat>,
debounce_in_ms: usize,
reset_debounce_on_update: bool,
error: Option<String>,
}
impl Default for AppState {
fn default() -> Self {
Self {
cats_to_fetch: 0,
cats: Vec::new(),
debounce_in_ms: 1000,
cats_are_being_fetched: false,
reset_debounce_on_update: true,
error: None,
}
}
}
async fn fetch_cats(count: usize) -> Result<Vec<Cat>, gloo_net::Error> {
log::debug!("Fetch {count} cats");
if count == 0 {
return Ok(Vec::new());
}
let url = format!("https://api.thecatapi.com/v1/images/search?limit={count}");
Ok(Request::get(&url)
.send()
.await?
.json::<Vec<Cat>>()
.await?
.into_iter()
.take(count)
.collect())
}
pub fn input_target<T>(event: &T) -> web_sys::HtmlInputElement
where
T: JsCast,
{
event
.unchecked_ref::<web_sys::Event>()
.target()
.unwrap_throw()
.unchecked_into::<web_sys::HtmlInputElement>()
}
fn app_logic(state: &mut AppState) -> impl HtmlDivElement<AppState> {
div((
cat_fetch_controls(state),
fork(
cat_images_and_fetching_indicator(state),
// Here's the actual fetching logic:
(state.cats_to_fetch < TOO_MANY_CATS).then_some(
memoized_await(
// This is given to the first closure right below which when resolved invokes the second closure with the output of the future,
// and when it changes, that first closure will be reevaluated again (similarly as the `Memoize` view).
// If `debounce_ms` below > `0`, then further updates (i.e. invocation of `fetch_cats`) are either throttled (when `!reset_debounce_on_update`),
// or debounced otherwise:
// As long as updates are happening within `debounce_in_ms` ms the first closure is not invoked, and a debounce timeout which runs `debounce_in_ms` is reset.
state.cats_to_fetch,
|count| fetch_cats(*count),
|state: &mut AppState, cats_result| match cats_result {
Ok(cats) => {
log::info!("Received {} cats", cats.len());
state.cats = cats;
state.cats_are_being_fetched = false;
state.error = None;
}
Err(err) => {
log::warn!("Unable to fetch cats: {err:#}");
state.cats_are_being_fetched = false;
state.error = Some(err.to_string());
}
},
)
.debounce_ms(state.debounce_in_ms)
.reset_debounce_on_update(state.reset_debounce_on_update),
),
),
))
}
fn cat_images_and_fetching_indicator(state: &AppState) -> impl HtmlDivElement<AppState> {
let cat_images = state
.cats
.iter()
.map(|cat| {
img(())
.src(cat.url.clone())
.attr("width", cat.width)
.attr("height", cat.height)
})
.collect::<Vec<_>>();
let error_message = state
.error
.as_ref()
.map(|err| div((h2("Error"), p(err.to_string()))).class("error"));
div((
error_message,
if state.cats_to_fetch != 0 && state.cats_to_fetch == cat_images.len() {
Either::A(h1("Here are your cats:").class("blink"))
} else if state.cats_to_fetch >= TOO_MANY_CATS {
Either::B(p("Woah there, that's too many cats"))
} else if state.debounce_in_ms > 0
&& state.cats_to_fetch > 0
&& state.reset_debounce_on_update
{
Either::B(p("Debounced fetch of cats..."))
} else if state.debounce_in_ms > 0 && state.cats_to_fetch > 0 {
Either::B(p("Throttled fetch of cats..."))
} else if state.cats_to_fetch > 0 && state.cats_are_being_fetched {
Either::B(p("Fetching cats..."))
} else {
Either::B(p("You need to fetch cats"))
},
cat_images,
))
}
fn cat_fetch_controls(state: &AppState) -> impl Element<AppState> {
fieldset((
legend("Cat fetch controls"),
table((
tr((
td(label("How many cats would you like?").for_("cat-count")),
td(input(())
.id("cat-count")
.attr("type", "number")
.attr("min", 0)
.attr("value", state.cats_to_fetch)
.on_input(|state: &mut AppState, ev: web_sys::Event| {
if !state.cats_are_being_fetched {
state.cats.clear();
}
state.cats_are_being_fetched = true;
state.cats_to_fetch = input_target(&ev).value().parse().unwrap_or(0);
})),
)),
tr((
td(
label("Reset fetch debounce timeout when updating the cat count:")
.for_("reset-debounce-update"),
),
td(input(())
.id("reset-debounce-update")
.attr("type", "checkbox")
.attr("checked", state.reset_debounce_on_update)
.on_input(|state: &mut AppState, event: web_sys::Event| {
state.reset_debounce_on_update = input_target(&event).checked();
})),
)),
tr((
td(label("Debounce timeout in ms:").for_("debounce-timeout-duration")),
td(input(())
.id("debounce-timeout-duration")
.attr("type", "number")
.attr("min", 0)
.attr("value", state.debounce_in_ms)
.on_input(|state: &mut AppState, ev: web_sys::Event| {
state.debounce_in_ms = input_target(&ev).value().parse().unwrap_or(0);
})),
)),
)),
))
.class("cat-fetch-controls")
}
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
log::info!("Start application");
App::new(document_body(), AppState::default(), app_logic).run();
}