use crate::{
errors::ClientError,
path::PathMaybeWithLocale,
reactor::Reactor,
state::{AnyFreeze, MakeRx, MakeUnrx, TemplateState, UnreactiveState},
};
use super::{Entity, PreloadInfo, TemplateInner};
use serde::{de::DeserializeOwned, Serialize};
use std::sync::Arc;
use sycamore::{
prelude::{create_child_scope, create_scope, BoundedScope, Scope, ScopeDisposer},
view::View,
web::Html,
};
/// The type of functions that are given a state and properties to render a
/// widget.
pub(crate) type CapsuleFn<G, P> = Box<
dyn for<'a> Fn(
Scope<'a>,
PreloadInfo,
TemplateState,
P,
PathMaybeWithLocale, // Widget path
PathMaybeWithLocale, // Caller path
) -> Result<(View<G>, ScopeDisposer<'a>), ClientError>
+ Send
+ Sync,
>;
/// A *capsule*, a special type of template in Perseus that can also accept
/// *properties*. Capsules are basically a very special type of Sycamore
/// component that can integrate fully with Perseus' state platform, generating
/// their own states at build-time, request-time, etc. They're then used in one
/// or more pages, and provided extra properties.
///
/// Note that capsules store their view functions and fallbacks independently of
/// their underlying templates, for properties support.
pub struct Capsule<G: Html, P: Clone + 'static> {
/// The underlying entity (in this case, a capsule).
pub(crate) inner: Entity<G>,
/// The capsule rendering function, which is a template function that also
/// takes properties.
capsule_view: CapsuleFn<G, P>,
/// A function that returns the fallback view to be rendered between when
/// the page is ready and when the capsule's state has been fetched.
///
/// Note that this starts as `None`, but, if it's not set, `PerseusApp` will
/// panic. So, for later code, this can be assumed to be always `Some`.
///
/// This will not be defined for templates, only for capsules.
#[allow(clippy::type_complexity)]
pub(crate) fallback: Option<Arc<dyn Fn(Scope, P) -> View<G> + Send + Sync>>,
}
impl<G: Html, P: Clone + 'static> std::fmt::Debug for Capsule<G, P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Capsule").finish()
}
}
/// The equivalent of [`TemplateInner`] for capsules.
///
/// # Implementation
///
/// Really, this is just a wrapper over [`TemplateInner`] with the additional
/// methods capsules need. For example, templates have fallback views on their
/// own, they just don't use them, and there's no way to set them as an end
/// user. This means Perseus can treat templates and capsules in the same way
/// internally, since they both have the same representation. Types like this
/// are mere convenience wrappers.
pub struct CapsuleInner<G: Html, P: Clone + 'static> {
template_inner: TemplateInner<G>,
capsule_view: CapsuleFn<G, P>,
/// A function that returns the fallback view to be rendered between when
/// the page is ready and when the capsule's state has been fetched.
///
/// Note that this starts as `None`, but, if it's not set, `PerseusApp` will
/// panic. So, for later code, this can be assumed to be always `Some`.
///
/// This will not be defined for templates, only for capsules.
#[allow(clippy::type_complexity)]
pub(crate) fallback: Option<Arc<dyn Fn(Scope, P) -> View<G> + Send + Sync>>,
}
impl<G: Html, P: Clone + 'static> std::fmt::Debug for CapsuleInner<G, P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CapsuleInner")
.field("template_inner", &self.template_inner)
.finish_non_exhaustive()
}
}
impl<G: Html, P: Clone + 'static> Capsule<G, P> {
/// Creates a new [`CapsuleInner`] from the given [`TemplateInner`]. In
/// Perseus, capsules are really just special kinds of pages, so you
/// create them by first creating the underlying template. To make sure
/// you get a capsule instead of a template, you just don't call
/// `.build()` on the template, instead passing the [`TemplateInner`] to
/// this function.
///
/// **Warning:** [`TemplateInner`] has methods like `.view()` and
/// `.view_with_state()` for setting the views of your templates, but you
/// shouldn't use those when you're building a capsule, because those
/// functions won't let you use *properties* that can be passed from
/// pages that use your capsule. Instead, construct a [`TemplateInner`]
/// that has no views, and then use the `.view()` etc. functions on
/// [`CapsuleInner`] instead. (Unfortunately, dereferncing doesn't work
/// with the builder pattern, so this is the best we can do in Rust
/// right now.)
///
/// You will need to call `.build()` when you're done with this to get a
/// full [`Capsule`].
pub fn build(mut template_inner: TemplateInner<G>) -> CapsuleInner<G, P> {
template_inner.is_capsule = true;
// Produce nice errors to make it clear that heads and headers don't work with
// capsules
#[cfg(engine)]
{
assert!(
template_inner.head.is_none(),
"capsules cannot set document metadata"
);
assert!(
template_inner.set_headers.is_none(),
"capsules cannot set headers"
);
}
// Wipe the template's view function to make sure the errors aren't obscenely
// weird
template_inner.view = Box::new(|_, _, _, _| Ok((View::empty(), create_scope(|_| {}))));
CapsuleInner {
template_inner,
capsule_view: Box::new(|_, _, _, _, _, _| Ok((View::empty(), create_scope(|_| {})))),
// This must be manually specified
fallback: None,
}
}
/// Executes the user-given function that renders the *widget* on the
/// client-side ONLY. This takes in an existing global state. This will
/// ignore its internal scope disposer, since the given scope **must**
/// be a page-level scope, which will be disposed from the root when the
/// page changes, thereby disposing of all the child scopes, like those
/// used for widgets.
///
/// This should NOT be used to render pages!
#[cfg(any(client, doc))]
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_widget_for_template_client(
&self,
path: PathMaybeWithLocale,
caller_path: PathMaybeWithLocale,
props: P,
cx: Scope,
preload_info: PreloadInfo,
) -> Result<View<G>, ClientError> {
// The template state is ignored by widgets, they fetch it themselves
// asynchronously
let (view, _disposer) = (self.capsule_view)(
cx,
preload_info,
TemplateState::empty(),
props,
path,
caller_path,
)?;
Ok(view)
}
/// Executes the user-given function that renders the capsule on the
/// server-side ONLY. This takes the scope from a previous call of
/// `.render_for_template_server()`, assuming the reactor has already
/// been fully instantiated.
#[cfg(engine)]
pub(crate) fn render_widget_for_template_server(
&self,
path: PathMaybeWithLocale,
state: TemplateState,
props: P,
cx: Scope,
) -> Result<View<G>, ClientError> {
// This is used for widget preloading, which doesn't occur on the engine-side
let preload_info = PreloadInfo {};
// We don't care about the scope disposer, since this scope is unique anyway;
// the caller path is also irrelevant except on the browser
let (view, _) = (self.capsule_view)(
cx,
preload_info,
state,
props,
path,
PathMaybeWithLocale(String::new()),
)?;
Ok(view)
}
}
impl<G: Html, P: Clone + 'static> CapsuleInner<G, P> {
/// Declares the fallback view to render for this capsule. When Perseus
/// renders a page of your app, it fetches the page itself, along with
/// all the capsules it needs. If the page is ready before all the
/// capsules, then it will be displayed immediately, with fallback views
/// for the capsules that aren't ready yet. Once they are ready, they
/// will be updated.
///
/// This fallback view cannot access any of the state that the capsule
/// generated, but it can access any properties provided to it by the
/// page, along with a translator and the like. This view is fully
/// reactive, it just doesn't have the state yet.
///
/// **Warning:** if you do not set a fallback view for a capsule, your app
/// will not compile!
pub fn fallback(mut self, view: impl Fn(Scope, P) -> View<G> + Send + Sync + 'static) -> Self {
{
self.fallback = Some(Arc::new(view));
}
self
}
/// Sets the fallback for this capsule to be an empty view.
///
/// You should be careful using this function in production, since it is
/// very often not what you actually want (especially since empty views
/// have no size, which may compromise your layouts: be sure to test
/// this).
pub fn empty_fallback(mut self) -> Self {
{
self.fallback = Some(Arc::new(|cx, _| sycamore::view! { cx, }));
}
self
}
/// Builds a full [`Capsule`] from this [`CapsuleInner`], consuming it in
/// the process. Once called, the capsule cannot be modified anymore,
/// and it will be placed into a smart pointer, allowing it to be cloned
/// freely with minimal costs.
///
/// You should call this just before you return your capsule.
pub fn build(self) -> Capsule<G, P> {
Capsule {
inner: Entity::from(self.template_inner),
capsule_view: self.capsule_view,
fallback: self.fallback,
}
}
// --- Shadow `.view()` functions for properties ---
// These will set dummy closures for the underlying templates, as capsules
// maintain their own separate functions, which can use properties in line
// with the known generics. As capsules are themselves used as their own
// components, these functions can therefore be accessed.
/// Sets the rendering function to use for capsules that take reactive
/// state. Capsules that do not take state should use `.view()` instead.
///
/// The closure wrapping this performs will automatically handle suspense
/// state.
// Generics are swapped here for nicer manual specification
pub fn view_with_state<I, F>(mut self, val: F) -> Self
where
// The state is made reactive on the child
F: for<'app, 'child> Fn(BoundedScope<'app, 'child>, &'child I, P) -> View<G>
+ Clone
+ Send
+ Sync
+ 'static,
I: MakeUnrx + AnyFreeze + Clone,
I::Unrx: MakeRx<Rx = I> + Serialize + DeserializeOwned + Send + Sync + Clone + 'static,
{
self.template_inner.view =
Box::new(|_, _, _, _| panic!("attempted to call template rendering logic for widget"));
#[cfg(any(client, doc))]
let entity_name = self.template_inner.get_path();
#[cfg(any(client, doc))]
let fallback_fn = self.fallback.clone(); // `Arc`ed, heaven help us
self.capsule_view = Box::new(
#[allow(unused_variables)]
move |app_cx, preload_info, template_state, props, path, caller_path| {
let reactor = Reactor::<G>::from_cx(app_cx);
reactor.get_widget_view::<I::Unrx, _, P>(
app_cx,
path,
caller_path,
#[cfg(any(client, doc))]
entity_name.clone(),
template_state,
props,
#[cfg(any(client, doc))]
preload_info,
val.clone(),
#[cfg(any(client, doc))]
fallback_fn.as_ref().unwrap(),
)
},
);
self
}
/// Sets the rendering function to use for capsules that take unreactive
/// state.
pub fn view_with_unreactive_state<F, S>(mut self, val: F) -> Self
where
F: Fn(Scope, S, P) -> View<G> + Clone + Send + Sync + 'static,
S: MakeRx + Serialize + DeserializeOwned + UnreactiveState + 'static,
<S as MakeRx>::Rx: AnyFreeze + Clone + MakeUnrx<Unrx = S>,
{
self.template_inner.view =
Box::new(|_, _, _, _| panic!("attempted to call template rendering logic for widget"));
#[cfg(any(client, doc))]
let entity_name = self.template_inner.get_path();
#[cfg(any(client, doc))]
let fallback_fn = self.fallback.clone(); // `Arc`ed, heaven help us
self.capsule_view = Box::new(
#[allow(unused_variables)]
move |app_cx, preload_info, template_state, props, path, caller_path| {
let reactor = Reactor::<G>::from_cx(app_cx);
reactor.get_unreactive_widget_view(
app_cx,
path,
caller_path,
#[cfg(any(client, doc))]
entity_name.clone(),
template_state,
props,
#[cfg(any(client, doc))]
preload_info,
val.clone(),
#[cfg(any(client, doc))]
fallback_fn.as_ref().unwrap(),
)
},
);
self
}
/// Sets the rendering function for capsules that take no state. Capsules
/// that do take state should use `.view_with_state()` instead.
pub fn view<F>(mut self, val: F) -> Self
where
F: Fn(Scope, P) -> View<G> + Send + Sync + 'static,
{
self.template_inner.view =
Box::new(|_, _, _, _| panic!("attempted to call template rendering logic for widget"));
self.capsule_view = Box::new(
#[allow(unused_variables)]
move |app_cx, _preload_info, _template_state, props, path, caller_path| {
let reactor = Reactor::<G>::from_cx(app_cx);
// Declare that this page/widget will never take any state to enable full
// caching
reactor.register_no_state(&path, true);
// And declare the relationship between the widget and its caller
#[cfg(any(client, doc))]
reactor.state_store.declare_dependency(&path, &caller_path);
// Nicely, if this is a widget, this means there need be no network requests
// at all!
let mut view = View::empty();
let disposer = create_child_scope(app_cx, |child_cx| {
view = val(child_cx, props);
});
Ok((view, disposer))
},
);
self
}
}