#[cfg(any(client, doc))]
mod error;
mod global_state;
#[cfg(all(feature = "hsr", debug_assertions, any(client, doc)))]
mod hsr;
#[cfg(any(client, doc))]
mod initial_load;
#[cfg(engine)]
mod render_mode;
#[cfg(any(client, doc))]
mod start;
mod state;
#[cfg(any(client, doc))]
mod subsequent_load;
mod widget_state;
#[cfg(any(client, doc))]
pub(crate) use initial_load::InitialView;
#[cfg(engine)]
pub(crate) use render_mode::{RenderMode, RenderStatus};
// --- Common imports ---
#[cfg(any(client, doc))]
use crate::template::{BrowserNodeType, EntityMap};
use crate::{
i18n::Translator,
state::{GlobalState, GlobalStateType, PageStateStore, TemplateState},
};
use sycamore::{
prelude::{provide_context, use_context, Scope},
web::Html,
};
// --- Engine-side imports ---
// --- Browser-side imports ---
#[cfg(any(client, doc))]
use crate::{
error_views::ErrorViews,
errors::ClientError,
errors::ClientInvariantError,
i18n::{ClientTranslationsManager, Locales, TranslationsManager},
init::PerseusAppBase,
plugins::PluginAction,
router::RouterState,
state::{FrozenApp, ThawPrefs},
stores::MutableStore,
};
#[cfg(any(client, doc))]
use serde::{de::DeserializeOwned, Serialize};
#[cfg(any(client, doc))]
use serde_json::Value;
#[cfg(any(client, doc))]
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
#[cfg(any(client, doc))]
use sycamore::{
reactive::{create_rc_signal, RcSignal},
view::View,
};
/// The core of Perseus' browser-side systems. This forms a central point for
/// all the Perseus state and rendering logic to operate from. In your own code,
/// this will always be available in the Sycamore context system.
///
/// Note that this is also used on the engine-side for rendering.
#[derive(Debug)]
pub struct Reactor<G: Html> {
/// The state store, which is used to hold all reactive states, along with
/// preloads.
pub(crate) state_store: PageStateStore,
/// The router state.
#[cfg(any(client, doc))]
pub router_state: RouterState,
/// The user-provided global state, stored with similar mechanics to the
/// state store, although optimised.
global_state: GlobalState,
// --- Browser-side only ---
/// A previous state the app was once in, still serialized. This will be
/// rehydrated gradually by the template closures.
///
/// The `bool` in here will be set to `true` if this was created through
/// HSR, which has slightly more lenient thawing procedures to allow for
/// data model changes.
#[cfg(any(client, doc))]
frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs, bool)>>>,
/// Whether or not this page is the very first to have been rendered since
/// the browser loaded the app. This will be reset on full reloads, and is
/// used internally to determine whether or not we should look for
/// stored HSR state.
#[cfg(any(client, doc))]
pub(crate) is_first: Cell<bool>,
/// The app's *full* render configuration. Note that a subset of this
/// is contained in the [`RenderMode`] on the engine-side for widget
/// rendering.
#[cfg(any(client, doc))]
pub(crate) render_cfg: HashMap<String, String>,
/// The app's templates and capsules for use in routing.
#[cfg(any(client, doc))]
pub(crate) entities: EntityMap<G>,
/// The app's locales.
#[cfg(any(client, doc))]
pub(crate) locales: Locales,
/// The browser-side translations manager.
#[cfg(any(client, doc))]
translations_manager: ClientTranslationsManager,
/// The app's error views.
#[cfg(any(client, doc))]
pub(crate) error_views: Rc<ErrorViews<G>>,
/// A reactive container for the current page-wide view. This will usually
/// contain the contents of the current page, but it may also contain a
/// page-wide error. This will be wrapped in a router.
#[cfg(any(client, doc))]
current_view: RcSignal<View<BrowserNodeType>>,
/// A reactive container for any popup errors.
#[cfg(any(client, doc))]
popup_error_view: RcSignal<View<BrowserNodeType>>,
/// The app's root div ID.
#[cfg(any(client, doc))]
root: String,
// --- Engine-side only ---
#[cfg(engine)]
pub(crate) render_mode: RenderMode<G>,
/// The currently active translator. On the browser-side, this is handled by
/// the more fully-fledged `ClientTranslationsManager` type.
///
/// This is provided to the engine-side reactor on instantiation. This can
/// be `None` in certain error view renders.
#[cfg(engine)]
translator: Option<Translator>,
}
// This uses window variables set by the HTML shell, so it should never be used
// on the engine-side
#[cfg(any(client, doc))]
impl<G: Html, M: MutableStore, T: TranslationsManager> TryFrom<PerseusAppBase<G, M, T>>
for Reactor<G>
{
type Error = ClientError;
fn try_from(app: PerseusAppBase<G, M, T>) -> Result<Self, Self::Error> {
let locales = app.get_locales()?;
let root = app.get_root()?;
let plugins = &app.plugins;
plugins
.functional_actions
.client_actions
.start
.run((), plugins.get_plugin_data())?;
// We need to fetch some things from window variables
let render_cfg =
match WindowVariable::<HashMap<String, String>>::new_obj("__PERSEUS_RENDER_CFG") {
WindowVariable::Some(render_cfg) => render_cfg,
WindowVariable::None | WindowVariable::Malformed => {
return Err(ClientInvariantError::RenderCfg.into())
}
};
let global_state_ty = match WindowVariable::<Value>::new_obj("__PERSEUS_GLOBAL_STATE") {
WindowVariable::Some(val) => {
let state = TemplateState::from_value(val);
if state.is_empty() {
// TODO Since we have it to hand, just make sure the global state creator really
// wasn't going to create anything (otherwise fail
// immediately)
GlobalStateType::None
} else {
GlobalStateType::Server(state)
}
}
WindowVariable::None => GlobalStateType::None,
WindowVariable::Malformed => return Err(ClientInvariantError::GlobalState.into()),
};
Ok(Self {
// This instantiates as if for the engine-side, but it will rapidly be changed
router_state: RouterState::default(),
state_store: PageStateStore::new(app.pss_max_size),
global_state: GlobalState::new(global_state_ty),
translations_manager: ClientTranslationsManager::new(&locales),
// This will be filled out by a `.thaw()` call or HSR
frozen_app: Rc::new(RefCell::new(None)),
is_first: Cell::new(true),
current_view: create_rc_signal(View::empty()),
popup_error_view: create_rc_signal(View::empty()),
entities: app.entities,
locales,
render_cfg,
#[cfg(debug_assertions)]
error_views: app.error_views.unwrap_or_default(),
#[cfg(not(debug_assertions))]
error_views: app
.error_views
.expect("you must provide your own error views in production"),
root,
})
}
}
impl<G: Html> Reactor<G> {
/// Adds `self` to the given Sycamore scope as context.
///
/// # Panics
/// This will panic if any other reactor is found in the context.
pub(crate) fn add_self_to_cx(self, cx: Scope) {
provide_context(cx, self);
}
/// Gets a [`Reactor`] out of the given Sycamore scope's context.
///
/// You should never need to worry about this function panicking, since
/// your code will only ever run if a reactor is present.
pub fn from_cx(cx: Scope) -> &Self {
use_context::<Self>(cx)
}
/// Gets the currently active translator.
///
/// On the browser-side, this will return `None` under some error
/// conditions, or before the initial load.
///
/// On the engine-side, this will return `None` under certain error
/// conditions.
#[cfg(any(client, doc))]
pub fn try_get_translator(&self) -> Option<Translator> {
self.translations_manager.get_translator()
}
/// Gets the currently active translator.
///
/// On the browser-side, this will return `None` under some error
/// conditions, or before the initial load.
///
/// On the engine-side, this will return `None` under certain error
/// conditions.
#[cfg(engine)]
pub fn try_get_translator(&self) -> Option<Translator> {
self.translator.clone()
}
/// Gets the currently active translator. Under some conditions, this will
/// panic: `.try_get_translator()` is available as a non-panicking
/// alternative.
///
/// # Panics
/// Panics if used before the initial load on the browser, when there isn't
/// a translator yet, or if used on the engine-side when a translator is
/// not available (which will be inside certain error views). Note that
/// an engine-side panic would occur as the server is serving a request,
/// which will lead to the request not being fulfilled.
pub fn get_translator(&self) -> Translator {
self.try_get_translator().expect("translator not available")
}
/// Switches the current locale to the given locale. This will navigate to
/// the current page in the given locale.
///
/// If a new page is being loaded, or if an error view is loaded, this will
/// simply have no effect whatsoever (to avoid users trying to switch
/// locales during a navigation and inadvertently causing a panic).
///
/// # Panics
///
/// This will panic if the given locale is not supported: use this only with
/// hardcoded locale values! This will also panic if used in an error
/// view without a translator.
#[cfg(client)]
pub fn switch_locale(&self, new_locale: &str) {
let path = self.router_state.get_path();
if let Some(path) = path {
let curr_locale = self.get_translator().get_locale();
let new_path = path.replace(&curr_locale, new_locale);
sycamore_router::navigate(&new_path);
}
}
}
#[cfg(engine)]
impl<G: Html> Reactor<G> {
/// Initializes a new [`Reactor`] on the engine-side.
pub(crate) fn engine(
global_state: TemplateState,
mode: RenderMode<G>,
translator: Option<&Translator>,
) -> Self {
Self {
state_store: PageStateStore::new(0), /* There will be no need for the state store on
* the
* server-side (but is still has to be
* accessible) */
global_state: if !global_state.is_empty() {
GlobalState::new(GlobalStateType::Server(global_state))
} else {
GlobalState::new(GlobalStateType::None)
},
render_mode: mode,
translator: translator.cloned(),
}
}
}
/// The possible states a window variable injected by the server/export process
/// can be found in.
#[cfg(any(client, doc))]
pub(crate) enum WindowVariable<T: Serialize + DeserializeOwned> {
/// It existed and coudl be deserialized into the correct type.
Some(T),
/// It was not present.
None,
/// It could not be deserialized into the correct type, or it was not
/// instantiated as the correct serialized type (e.g. expected to find a
/// string to be deserialized, found a boolean instead).
Malformed,
}
#[cfg(any(client, doc))]
impl<T: Serialize + DeserializeOwned> WindowVariable<T> {
/// Gets the window variable of the given name, attempting to fetch it as
/// the given type. This will only work with window variables that have
/// been serialized to strings from the given type `T`.
fn new_obj(name: &str) -> Self {
let val_opt = web_sys::window().unwrap().get(name);
let js_obj = match val_opt {
Some(js_obj) => js_obj,
None => return Self::None,
};
// The object should only actually contain the string value that was injected
let val_str = match js_obj.as_string() {
Some(val_str) => val_str,
None => return Self::Malformed,
};
let val_typed = match serde_json::from_str::<T>(&val_str) {
Ok(typed) => typed,
Err(_) => return Self::Malformed,
};
Self::Some(val_typed)
}
}
#[cfg(any(client, doc))]
impl WindowVariable<bool> {
/// Gets the window variable of the given name, attempting to fetch it as
/// the given type. This will only work with boolean window variables.
///
/// While it may seem that a boolean cannot be 'malformed', it most
/// certainly can be if you think it is boolean, but it actually isn't!
///
/// This is generally used internally for managing flags.
pub(crate) fn new_bool(name: &str) -> Self {
let val_opt = web_sys::window().unwrap().get(name);
let js_bool = match val_opt {
Some(js_bool) => js_bool,
None => return Self::None,
};
// The object should only actually contain the boolean value that was injected
match js_bool.as_bool() {
Some(val) => Self::Some(val),
None => Self::Malformed,
}
}
}
#[cfg(any(client, doc))]
impl WindowVariable<String> {
/// Gets the window variable of the given name, attempting to fetch it as
/// the given type. This will only work with `String` window variables.
fn new_str(name: &str) -> Self {
let val_opt = web_sys::window().unwrap().get(name);
let js_str = match val_opt {
Some(js_str) => js_str,
None => return Self::None,
};
// The object should only actually contain the boolean value that was injected
match js_str.as_string() {
Some(val) => Self::Some(val),
None => Self::Malformed,
}
}
}