Suspense/Transition components

This commit is contained in:
Greg Johnston 2024-03-26 21:38:10 -04:00
parent b8b77138ea
commit 88b93f40f9
16 changed files with 300 additions and 468 deletions

View File

@ -36,6 +36,7 @@ members = [
"router",
"routing",
"is_server",
"routing_macro",
]
exclude = ["benchmarks", "examples"]

View File

@ -8,13 +8,17 @@ codegen-units = 1
lto = true
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos", features = ["csr", "tracing"] }
reqwasm = "0.5"
gloo-timers = { version = "0.3", features = ["futures"] }
serde = { version = "1", features = ["derive"] }
log = "0.4"
console_log = "1"
console_error_panic_hook = "0.1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber-wasm = "0.1"
[dev-dependencies]
wasm-bindgen-test = "0.3"

View File

@ -5,7 +5,7 @@ use leptos::{
computed::AsyncDerived,
signal::{signal, RwSignal},
},
view, IntoView,
view, IntoView, Transition,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@ -46,7 +46,7 @@ async fn fetch_cats(count: CatCount) -> Result<Vec<String>> {
}
pub fn fetch_example() -> impl IntoView {
let (cat_count, set_cat_count) = signal::<CatCount>(0);
let (cat_count, set_cat_count) = signal::<CatCount>(3);
// we use new_unsync here because the reqwasm request type isn't Send
// if we were doing SSR, then
@ -73,22 +73,6 @@ pub fn fetch_example() -> impl IntoView {
}
};*/
let cats_view = move || {
async move {
cats.await
.map(|cats| {
cats.into_iter()
.map(|s| view! { <p><img src={s}/></p> })
.collect::<Vec<_>>()
})
.catch(|err| view! { <p class="error">{err.to_string()}</p> })
}
.suspend()
.transition()
.track()
.with_fallback(|| view! { <div>"Loading..."</div>})
};
view! {
<div>
<label>
@ -102,7 +86,17 @@ pub fn fetch_example() -> impl IntoView {
}
/>
</label>
{cats_view}
<Transition fallback=|| view! { <div>"Loading..."</div> }>
{async move {
cats.await
.map(|cats| {
cats.into_iter()
.map(|s| view! { <p><img src={s}/></p> })
.collect::<Vec<_>>()
})
.unwrap_or_default()
}}
</Transition>
</div>
}
}

View File

@ -2,7 +2,20 @@ use fetch::fetch_example;
use leptos::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
use tracing_subscriber::fmt;
use tracing_subscriber_wasm::MakeConsoleWriter;
fmt()
.with_writer(
// To avoide trace events in the browser from showing their
// JS backtrace, which is very annoying, in my opinion
MakeConsoleWriter::default()
.map_trace_level_to(tracing::Level::DEBUG),
)
// For some reason, if we don't do this in the browser, we get
// a runtime error.
.without_time()
.init();
console_error_panic_hook::set_once();
mount_to_body(fetch_example)
}

View File

@ -158,7 +158,7 @@ where
/// New-type wrapper for the a function that returns a view with `From` and `Default` traits implemented
/// to enable optional props in for example `<Show>` and `<Suspense>`.
#[derive(Clone)]
pub struct ViewFn(Arc<dyn Fn() -> AnyView<Dom>>);
pub struct ViewFn(Arc<dyn Fn() -> AnyView<Dom> + Send + Sync + 'static>);
impl Default for ViewFn {
fn default() -> Self {
@ -168,7 +168,7 @@ impl Default for ViewFn {
impl<F, C> From<F> for ViewFn
where
F: Fn() -> C + 'static,
F: Fn() -> C + Send + Sync + 'static,
C: RenderHtml<Dom> + 'static,
{
fn from(value: F) -> Self {

View File

@ -161,7 +161,9 @@ mod hydration_scripts;
#[cfg(feature = "nonce")]
pub mod nonce;
mod show;
mod suspense_component;
pub mod text_prop;
mod transition;
pub use for_loop::*;
pub use hydration_scripts::*;
pub use leptos_macro::*;
@ -171,6 +173,8 @@ pub use reactive_graph::{
};
pub use server_fn::{self, error};
pub use show::*;
pub use suspense_component::*;
pub use transition::*;
#[doc(hidden)]
pub use typed_builder;
#[doc(hidden)]
@ -266,7 +270,7 @@ pub use serde;
pub use serde_json;
pub use show::*;
//pub use suspense_component::*;
//mod suspense_component;
mod suspense_component;
//mod transition;
#[cfg(any(debug_assertions, feature = "ssr"))]
#[doc(hidden)]

View File

@ -1,272 +1,54 @@
use leptos::ViewFn;
use leptos_dom::{DynChild, HydrationCtx, IntoView};
use crate::{children::ToChildren};
use tachys::prelude::FutureViewExt;
use crate::{children::ViewFn, IntoView};
use leptos_macro::component;
#[allow(unused)]
use leptos_reactive::SharedContext;
#[cfg(any(feature = "csr", feature = "hydrate"))]
use leptos_reactive::SignalGet;
use leptos_reactive::{
create_memo, provide_context, SignalGetUntracked, SuspenseContext,
};
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
use leptos_reactive::{with_owner, Owner};
use std::rc::Rc;
use std::{future::Future, sync::Arc};
/// If any [`Resource`](leptos_reactive::Resource) is read in the `children` of this
/// component, it will show the `fallback` while they are loading. Once all are resolved,
/// it will render the `children`.
///
/// Note that the `children` will be rendered initially (in order to capture the fact that
/// those resources are read under the suspense), so you cannot assume that resources have
/// `Some` value in `children`.
///
/// ```
/// # use leptos_reactive::*;
/// # use leptos_macro::*;
/// # use leptos_dom::*; use leptos::*;
/// # if false {
/// # let runtime = create_runtime();
/// async fn fetch_cats(how_many: u32) -> Option<Vec<String>> { Some(vec![]) }
///
/// let (cat_count, set_cat_count) = create_signal::<u32>(1);
///
/// let cats = create_resource(move || cat_count.get(), |count| fetch_cats(count));
///
/// view! {
/// <div>
/// <Suspense fallback=move || view! { <p>"Loading (Suspense Fallback)..."</p> }>
/// {move || {
/// cats.get().map(|data| match data {
/// None => view! { <pre>"Error"</pre> }.into_view(),
/// Some(cats) => cats
/// .iter()
/// .map(|src| {
/// view! {
/// <img src={src}/>
/// }
/// })
/// .collect_view(),
/// })
/// }
/// }
/// </Suspense>
/// </div>
/// };
/// # runtime.dispose();
/// # }
/// ```
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
tracing::instrument(level = "trace", skip_all)
)]
/// An async, typed equivalent to [`Children`], which takes a generic but preserves
/// type information to allow the compiler to optimize the view more effectively.
pub struct AsyncChildren<T, F, Fut>(pub(crate) F)
where
F: Fn() -> Fut,
Fut: Future<Output = T>;
impl<T, F, Fut> AsyncChildren<T, F, Fut>
where
F: Fn() -> Fut,
Fut: Future<Output = T>,
{
pub fn into_inner(self) -> F {
self.0
}
}
impl<T, F, Fut> ToChildren<F> for AsyncChildren<T, F, Fut>
where
F: Fn() -> Fut,
Fut: Future<Output = T>,
{
fn to_children(f: F) -> Self {
AsyncChildren(f)
}
}
/// TODO docs!
#[component]
pub fn Suspense<V>(
/// Returns a fallback UI that will be shown while `async` [`Resource`](leptos_reactive::Resource)s are still loading. By default this is the empty view.
#[prop(optional, into)]
fallback: ViewFn,
/// Children will be displayed once all `async` [`Resource`](leptos_reactive::Resource)s have resolved.
children: Rc<dyn Fn() -> V>,
pub fn Suspense<Chil, ChilFn, ChilFut>(
#[prop(optional, into)] fallback: ViewFn,
children: AsyncChildren<Chil, ChilFn, ChilFut>,
) -> impl IntoView
where
V: IntoView + 'static,
Chil: IntoView + 'static,
ChilFn: Fn() -> ChilFut + Clone + 'static,
ChilFut: Future<Output = Chil> + Send + Sync + 'static,
{
#[cfg(all(
feature = "experimental-islands",
not(any(feature = "csr", feature = "hydrate"))
))]
let no_hydrate = SharedContext::no_hydrate();
let orig_children = children;
let context = SuspenseContext::new();
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
let owner =
Owner::current().expect("<Suspense/> created with no reactive owner");
let current_id = HydrationCtx::next_component();
// provide this SuspenseContext to any resources below it
// run in a memo so the children are children of this parent
#[cfg(not(feature = "hydrate"))]
let children = create_memo({
let orig_children = Rc::clone(&orig_children);
move |_| {
provide_context(context);
orig_children().into_view()
}
});
#[cfg(feature = "hydrate")]
let children = create_memo({
let orig_children = Rc::clone(&orig_children);
move |_| {
provide_context(context);
if SharedContext::fragment_has_local_resources(
&current_id.to_string(),
) {
HydrationCtx::with_hydration_off({
let orig_children = Rc::clone(&orig_children);
move || orig_children().into_view()
})
} else {
orig_children().into_view()
}
}
});
// likewise for the fallback
let fallback = create_memo({
move |_| {
provide_context(context);
fallback.run()
}
});
#[cfg(any(feature = "csr", feature = "hydrate"))]
let ready = context.ready();
let child = DynChild::new({
move || {
// pull lazy memo before checking if context is ready
let children_rendered = children.get_untracked();
#[cfg(any(feature = "csr", feature = "hydrate"))]
{
if ready.get() {
children_rendered
} else {
fallback.get_untracked()
}
}
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
{
use leptos_reactive::signal_prelude::*;
// run the child; we'll probably throw this away, but it will register resource reads
//let after_original_child = HydrationCtx::peek();
{
// no resources were read under this, so just return the child
if context.none_pending() {
with_owner(owner, move || {
//HydrationCtx::continue_from(current_id);
DynChild::new(move || children_rendered.clone())
.into_view()
})
} else if context.has_any_local() {
SharedContext::register_local_fragment(
current_id.to_string(),
);
fallback.get_untracked()
}
// show the fallback, but also prepare to stream HTML
else {
HydrationCtx::continue_from(current_id);
let runtime = leptos_reactive::current_runtime();
SharedContext::register_suspense(
context,
&current_id.to_string(),
// out-of-order streaming
{
let orig_children = Rc::clone(&orig_children);
move || {
leptos_reactive::set_current_runtime(
runtime,
);
#[cfg(feature = "experimental-islands")]
let prev_no_hydrate =
SharedContext::no_hydrate();
#[cfg(feature = "experimental-islands")]
{
SharedContext::set_no_hydrate(
no_hydrate,
);
}
let rendered = with_owner(owner, {
move || {
HydrationCtx::continue_from(
current_id,
);
DynChild::new({
move || {
orig_children().into_view()
}
})
.into_view()
.render_to_string()
.to_string()
}
});
#[cfg(feature = "experimental-islands")]
SharedContext::set_no_hydrate(
prev_no_hydrate,
);
#[allow(clippy::let_and_return)]
rendered
}
},
// in-order streaming
{
let orig_children = Rc::clone(&orig_children);
move || {
leptos_reactive::set_current_runtime(
runtime,
);
#[cfg(feature = "experimental-islands")]
let prev_no_hydrate =
SharedContext::no_hydrate();
#[cfg(feature = "experimental-islands")]
{
SharedContext::set_no_hydrate(
no_hydrate,
);
}
let rendered = with_owner(owner, {
move || {
HydrationCtx::continue_from(
current_id,
);
DynChild::new({
move || {
orig_children().into_view()
}
})
.into_view()
.into_stream_chunks()
}
});
#[cfg(feature = "experimental-islands")]
SharedContext::set_no_hydrate(
prev_no_hydrate,
);
#[allow(clippy::let_and_return)]
rendered
}
},
);
// return the fallback for now, wrapped in fragment identifier
fallback.get_untracked()
}
}
}
}
})
.into_view();
let core_component = match child {
leptos_dom::View::CoreComponent(repr) => repr,
_ => unreachable!(),
};
HydrationCtx::continue_from(current_id);
HydrationCtx::next_component();
leptos_dom::View::Suspense(current_id, core_component)
let children = Arc::new(children.into_inner());
// TODO check this against islands
move || {
children()
.suspend()
.with_fallback(fallback.run())
.track()
}
}

View File

@ -1,177 +1,25 @@
use leptos::ViewFn;
use leptos_dom::{Fragment, HydrationCtx, IntoView, View};
use crate::{children::ViewFn, AsyncChildren, IntoView};
use leptos_macro::component;
use leptos_reactive::{
create_isomorphic_effect, create_rw_signal, use_context, RwSignal,
SignalGet, SignalGetUntracked, SignalSet, SignalSetter, SuspenseContext,
};
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use std::{future::Future, sync::Arc};
use tachys::prelude::FutureViewExt;
/// If any [`Resource`](leptos_reactive::Resource)s are read in the `children` of this
/// component, it will show the `fallback` while they are loading. Once all are resolved,
/// it will render the `children`. Unlike [`Suspense`](crate::Suspense), this will not fall
/// back to the `fallback` state if there are further changes after the initial load.
///
/// Note that the `children` will be rendered initially (in order to capture the fact that
/// those resources are read under the suspense), so you cannot assume that resources have
/// `Some` value in `children`.
///
/// ```
/// # use leptos_reactive::*;
/// # use leptos_macro::*;
/// # use leptos_dom::*;
/// # use leptos::*;
/// # if false {
/// # let runtime = create_runtime();
/// async fn fetch_cats(how_many: u32) -> Option<Vec<String>> {
/// Some(vec![])
/// }
///
/// let (cat_count, set_cat_count) = create_signal::<u32>(1);
/// let (pending, set_pending) = create_signal(false);
///
/// let cats =
/// create_resource(move || cat_count.get(), |count| fetch_cats(count));
///
/// view! {
/// <div>
/// <Transition
/// fallback=move || view! { <p>"Loading..."</p>}
/// set_pending
/// >
/// {move || {
/// cats.read().map(|data| match data {
/// None => view! { <pre>"Error"</pre> }.into_view(),
/// Some(cats) => cats
/// .iter()
/// .map(|src| {
/// view! {
/// <img src={src}/>
/// }
/// })
/// .collect_view(),
/// })
/// }
/// }
/// </Transition>
/// </div>
/// };
/// # runtime.dispose();
/// # }
/// ```
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
tracing::instrument(level = "trace", skip_all)
)]
#[component(transparent)]
pub fn Transition(
/// Will be displayed while resources are pending. By default this is the empty view.
#[prop(optional, into)]
fallback: ViewFn,
/// A function that will be called when the component transitions into or out of
/// the `pending` state, with its argument indicating whether it is pending (`true`)
/// or not pending (`false`).
#[prop(optional, into)]
set_pending: Option<SignalSetter<bool>>,
/// Will be displayed once all resources have resolved.
children: Box<dyn Fn() -> Fragment>,
) -> impl IntoView {
let prev_children = Rc::new(RefCell::new(None::<View>));
let first_run = create_rw_signal(true);
let child_runs = Cell::new(0);
let held_suspense_context = Rc::new(RefCell::new(None::<SuspenseContext>));
crate::Suspense(
crate::SuspenseProps::builder()
.fallback({
let prev_child = Rc::clone(&prev_children);
move || {
let suspense_context = use_context::<SuspenseContext>()
.expect("there to be a SuspenseContext");
let was_first_run =
cfg!(feature = "csr") && first_run.get();
let is_first_run =
is_first_run(first_run, &suspense_context);
if was_first_run {
first_run.set(false)
}
if let Some(prev_children) = &*prev_child.borrow() {
if is_first_run || was_first_run {
fallback.run()
} else {
prev_children.clone()
}
} else {
fallback.run()
}
}
})
.children(Rc::new(move || {
let frag = children().into_view();
if let Some(suspense_context) = use_context::<SuspenseContext>()
{
*held_suspense_context.borrow_mut() =
Some(suspense_context);
}
let suspense_context = held_suspense_context.borrow().unwrap();
if cfg!(feature = "hydrate")
|| !first_run.get_untracked()
|| (cfg!(feature = "csr") && first_run.get())
{
*prev_children.borrow_mut() = Some(frag.clone());
}
if is_first_run(first_run, &suspense_context) {
let has_local_only = suspense_context.has_local_only()
|| cfg!(feature = "csr")
|| !HydrationCtx::is_hydrating();
if (!has_local_only || child_runs.get() > 0)
&& !cfg!(feature = "csr")
{
first_run.set(false);
}
}
child_runs.set(child_runs.get() + 1);
create_isomorphic_effect(move |_| {
if let Some(set_pending) = set_pending {
set_pending.set(!suspense_context.none_pending())
}
});
frag
}))
.build(),
)
}
fn is_first_run(
first_run: RwSignal<bool>,
suspense_context: &SuspenseContext,
) -> bool {
if cfg!(feature = "csr")
|| (cfg!(feature = "hydrate") && !HydrationCtx::is_hydrating())
{
false
} else {
match (
first_run.get_untracked(),
cfg!(feature = "hydrate"),
suspense_context.has_local_only(),
) {
(false, _, _) => false,
// SSR and has non-local resources (so, has streamed)
(_, false, false) => false,
// SSR but with only local resources (so, has not streamed)
(_, false, true) => true,
// hydrate: it's the first run
(first_run, true, _) => HydrationCtx::is_hydrating() || first_run,
}
/// TODO docs!
#[component]
pub fn Transition<Chil, ChilFn, ChilFut>(
#[prop(optional, into)] fallback: ViewFn,
children: AsyncChildren<Chil, ChilFn, ChilFut>,
) -> impl IntoView
where
Chil: IntoView + 'static,
ChilFn: Fn() -> ChilFut + Clone + 'static,
ChilFut: Future<Output = Chil> + Send + Sync + 'static,
{
let children = children.into_inner();
move || {
children()
.suspend()
.transition()
.with_fallback(fallback.run())
.track()
}
}

View File

@ -37,9 +37,23 @@ impl ReactiveNode for RwLock<ArcAsyncDerivedInner> {
}
fn update_if_necessary(&self) -> bool {
// always return false, because the async work will not be ready yet
// we'll mark subscribers dirty again when it resolves
false
// if update_is_necessary is being called, that mean that a subscriber
// wants to know if our latest value has changed
//
// this could be the case either because
// 1) we have updated, and asynchronously woken the subscriber back up
// 2) a different source has woken up the subscriber, and it's now asking us
// if we've changed
//
// if we return `false` it will short-circuit that subscriber
// if we return `true` it means "yes, we may have changed"
//
// returning `true` here means that an AsyncDerived behaves like a signal (it always says
// "sure, I"ve changed) and not like a memo (checks whether it has *actually* changed)
//
// TODO is there a dirty-checking mechanism that would work here? we would need a
// memoization process like a memo has, to ensure we don't over-notify
true
}
}

View File

@ -4,6 +4,7 @@ edition = "2021"
version.workspace = true
[dependencies]
leptos = { workspace = true }
any_spawner = { workspace = true }
either_of = { workspace = true }
reactive_graph = { workspace = true }

70
routing/src/components.rs Normal file
View File

@ -0,0 +1,70 @@
use crate::{
location::BrowserUrl, matching, router, FlatRouter, NestedRoute, RouteData,
Router, Routes,
};
use leptos::{children::ToChildren, component};
use std::borrow::Cow;
use tachys::renderer::dom::Dom;
#[derive(Debug)]
pub struct RouteChildren<Children>(Children);
impl<Children> RouteChildren<Children> {
pub fn into_inner(self) -> Children {
self.0
}
}
impl<F, Children> ToChildren<F> for RouteChildren<Children>
where
F: FnOnce() -> Children,
{
fn to_children(f: F) -> Self {
RouteChildren(f())
}
}
#[component]
pub fn FlatRouter<Children, FallbackFn, Fallback>(
#[prop(optional, into)] base: Option<Cow<'static, str>>,
fallback: FallbackFn,
children: RouteChildren<Children>,
) -> FlatRouter<Dom, BrowserUrl, Children, FallbackFn>
where
FallbackFn: Fn() -> Fallback,
{
let children = Routes::new(children.into_inner());
if let Some(base) = base {
FlatRouter::new_with_base(base, children, fallback)
} else {
FlatRouter::new(children, fallback)
}
}
#[component]
pub fn Router<Children, FallbackFn, Fallback>(
#[prop(optional, into)] base: Option<Cow<'static, str>>,
fallback: FallbackFn,
children: RouteChildren<Children>,
) -> Router<Dom, BrowserUrl, Children, FallbackFn>
where
FallbackFn: Fn() -> Fallback,
{
let children = Routes::new(children.into_inner());
if let Some(base) = base {
Router::new_with_base(base, children, fallback)
} else {
Router::new(children, fallback)
}
}
#[component]
pub fn Route<Segments, View, ViewFn>(
path: Segments,
view: ViewFn,
) -> NestedRoute<Segments, (), (), ViewFn, Dom>
where
ViewFn: Fn(RouteData<Dom>) -> View,
{
NestedRoute::new(path, view)
}

View File

@ -1,4 +1,4 @@
//mod reactive;
pub mod components;
mod generate_route_list;
pub mod location;
mod matching;
@ -7,7 +7,7 @@ mod params;
mod router;
mod ssr_mode;
mod static_route;
//pub use reactive::*;
pub use generate_route_list::*;
pub use matching::*;
pub use method::*;

16
routing_macro/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "routing_macro"
edition = "2021"
version.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro-error = { version = "1", default-features = false }
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
[dev-dependencies]
routing = { workspace = true }

76
routing_macro/src/lib.rs Normal file
View File

@ -0,0 +1,76 @@
use proc_macro::{TokenStream, TokenTree};
use quote::{quote, ToTokens};
use std::borrow::Cow;
use syn::{
parse::{Parse, ParseStream},
parse_macro_input,
token::Token,
};
#[proc_macro_error::proc_macro_error]
#[proc_macro]
pub fn path(tokens: TokenStream) -> TokenStream {
let mut parser = SegmentParser::new(tokens);
parser.parse_all();
let segments = Segments(parser.segments);
segments.into_token_stream().into()
}
#[derive(Debug, PartialEq)]
struct Segments(pub Vec<Segment>);
#[derive(Debug, PartialEq)]
enum Segment {
Static(Cow<'static, str>),
}
struct SegmentParser {
input: proc_macro::token_stream::IntoIter,
current_str: Option<String>,
segments: Vec<Segment>,
}
impl SegmentParser {
pub fn new(input: TokenStream) -> Self {
Self {
input: input.into_iter(),
current_str: None,
segments: Vec::new(),
}
}
}
impl SegmentParser {
pub fn parse_all(&mut self) {
for input in self.input.by_ref() {
match input {
TokenTree::Literal(lit) => {
Self::parse_str(
lit.to_string()
.trim_start_matches(['"', '/'])
.trim_end_matches(['"', '/']),
&mut self.segments,
);
}
TokenTree::Group(_) => todo!(),
TokenTree::Ident(_) => todo!(),
TokenTree::Punct(_) => todo!(),
}
}
}
pub fn parse_str(current_str: &str, segments: &mut Vec<Segment>) {
let mut chars = current_str.chars();
}
}
impl ToTokens for Segments {
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
let children = quote! {};
if self.0.len() != 1 {
tokens.extend(quote! { (#children) });
} else {
tokens.extend(children)
}
}
}

View File

@ -0,0 +1,9 @@
use routing::StaticSegment;
use routing_macro::path;
#[test]
fn parses_empty_list() {
let output = path!("");
assert_eq!(output, ());
//let segments: Segments = syn::parse(path.into()).unwrap();
}

View File

@ -139,7 +139,7 @@ where
impl<const TRANSITION: bool, Fal, Fut, Rndr> RenderHtml<Rndr>
for Suspend<TRANSITION, Fal, Fut>
where
Fal: RenderHtml<Rndr> + Send + Sync + 'static,
Fal: RenderHtml<Rndr> + 'static,
Fut: Future + Send + Sync + 'static,
Fut::Output: RenderHtml<Rndr>,
Rndr: Renderer + 'static,