diff --git a/docs/next/en-US/reference/faq.md b/docs/next/en-US/reference/faq.md index 841701824a..22faf87fc1 100644 --- a/docs/next/en-US/reference/faq.md +++ b/docs/next/en-US/reference/faq.md @@ -38,3 +38,7 @@ Sycamore v0.8.0 has been released in beta to solve these problems and many other These macros are simple proxies over the more longwinded `#[cfg(target_arch = "wasm32")]` and the negation of that, respectively. They can be easily applied to functions, `struct`s, and other 'block'-style items in Rust. However, you won't be able to apply them to statements (e.g. `call_my_function();`) , since Rust's [proc macro hygiene](https://github.com/rust-lang/rust/issues/54727) doesn't allow this yet. If you need to use stable Rust, you'll have to go with the longwinded versions in these places, or you could alternatively create a version of the functions you need to call for the desired platform, and then a dummy version for the other that doesn't do anything (effectively moving the target-gating upstream). The best solution, however, is to switch to nightly Rust (`rustup override set nightly`) and then add `#![feature(proc_macro_hygiene)]` to the top of your `main.rs`, which should fix this. + +## I'm getting really weird errors with a page's ``... + +Alright, this can mean about a million things. There is one that could be known to be Perseus' fault though: if you go to a page in your app, then reload it, then go to another page, and then navigate *back* to the original page (using a link inside your app, *not* your browser's back button), and there are problems with the `` that weren't there before, then you should disable the `cache-initial-load` feature on Perseus, since Perseus is having problems figuring out how your `` works. Typically, a delimiter `` is added to the end of the ``, but if you're using a plugin that's adding anything essential after this, that will be lost on transition to the new page. Any advanced manipulation of the `` at runtime could also cause this. Note that disabling this feature (which is on by default) will prevent caching of the first page the user loads, and it will have to be re-requested if they go back to it, which incurs the penalty of a network request. diff --git a/examples/.base/Cargo.toml b/examples/.base/Cargo.toml index 07e0f1e19e..c72f01ce6f 100644 --- a/examples/.base/Cargo.toml +++ b/examples/.base/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] } -sycamore = "=0.8.0-beta.7" +sycamore = "^0.8.1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/examples/core/preload/.gitignore b/examples/core/preload/.gitignore new file mode 100644 index 0000000000..6df95ee360 --- /dev/null +++ b/examples/core/preload/.gitignore @@ -0,0 +1,3 @@ +dist/ +target_engine/ +target_wasm/ diff --git a/examples/core/preload/Cargo.toml b/examples/core/preload/Cargo.toml new file mode 100644 index 0000000000..b2b825a8bd --- /dev/null +++ b/examples/core/preload/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "perseus-example-preload" +version = "0.4.0-beta.10" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] } +sycamore = "^0.8.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +fantoccini = "0.17" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] } +## **WARNING!** Before running this example outside the Perseus repo, replace the below line with +## the one commented out below it (changing the path dependency to the version you want to use) +perseus-warp = { package = "perseus-integration", path = "../../../packages/perseus-integration", default-features = false } +# perseus-warp = { path = "../../../packages/perseus-warp", features = [ "dlft-server" ] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/examples/core/preload/README.md b/examples/core/preload/README.md new file mode 100644 index 0000000000..a1e523331f --- /dev/null +++ b/examples/core/preload/README.md @@ -0,0 +1,3 @@ +# Preload Example + +This example demonstrates Perseus' inbuilt imperative preloading functionality, which allows downloading all the assets needed to render a page ahead-of-time, so that, when the user reaches that page, they can go to it without any network requests being needed! diff --git a/examples/core/preload/src/error_pages.rs b/examples/core/preload/src/error_pages.rs new file mode 100644 index 0000000000..2ad326416c --- /dev/null +++ b/examples/core/preload/src/error_pages.rs @@ -0,0 +1,32 @@ +use perseus::{ErrorPages, Html}; +use sycamore::view; + +pub fn get_error_pages() -> ErrorPages { + let mut error_pages = ErrorPages::new( + |cx, url, status, err, _| { + view! { cx, + p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) } + } + }, + |cx, _, _, _, _| { + view! { cx, + title { "Error" } + } + }, + ); + error_pages.add_page( + 404, + |cx, _, _, _, _| { + view! { cx, + p { "Page not found." } + } + }, + |cx, _, _, _, _| { + view! { cx, + title { "Not Found" } + } + }, + ); + + error_pages +} diff --git a/examples/core/preload/src/main.rs b/examples/core/preload/src/main.rs new file mode 100644 index 0000000000..eee8b18aa3 --- /dev/null +++ b/examples/core/preload/src/main.rs @@ -0,0 +1,12 @@ +mod error_pages; +mod templates; + +use perseus::{Html, PerseusApp}; + +#[perseus::main(perseus_warp::dflt_server)] +pub fn main() -> PerseusApp { + PerseusApp::new() + .template(crate::templates::index::get_template) + .template(crate::templates::about::get_template) + .error_pages(crate::error_pages::get_error_pages) +} diff --git a/examples/core/preload/src/templates/about.rs b/examples/core/preload/src/templates/about.rs new file mode 100644 index 0000000000..7360b31a4b --- /dev/null +++ b/examples/core/preload/src/templates/about.rs @@ -0,0 +1,16 @@ +use perseus::{Html, Template}; +use sycamore::prelude::{view, Scope}; +use sycamore::view::View; + +#[perseus::template_rx] +pub fn about_page(cx: Scope) -> View { + view! { cx, + p { "Check out your browser's network DevTools, no new requests were needed to get to this page!" } + + a(id = "index-link", href = "") { "Index" } + } +} + +pub fn get_template() -> Template { + Template::new("about").template(about_page) +} diff --git a/examples/core/preload/src/templates/index.rs b/examples/core/preload/src/templates/index.rs new file mode 100644 index 0000000000..25cd56ba71 --- /dev/null +++ b/examples/core/preload/src/templates/index.rs @@ -0,0 +1,34 @@ +use perseus::Template; +use sycamore::prelude::{view, Html, Scope, SsrNode, View}; + +#[perseus::template_rx] +pub fn index_page(cx: Scope) -> View { + // We can't preload pages on the engine-side + #[cfg(target_arch = "wasm32")] + { + // Get the render context first, which is the one-stop-shop for everything + // internal to Perseus in the browser + let render_ctx = perseus::get_render_ctx!(cx); + // This spawns a future in the background, and will panic if the page you give + // doesn't exist (to handle those errors and manage the future, use + // `.try_preload` instead) + render_ctx.preload(cx, "about"); + } + + view! { cx, + p { "Open up your browser's DevTools, go to the network tab, and then click the link below..." } + + a(href = "about") { "About" } + } +} + +#[perseus::head] +pub fn head(cx: Scope) -> View { + view! { cx, + title { "Index Page" } + } +} + +pub fn get_template() -> Template { + Template::new("index").template(index_page).head(head) +} diff --git a/examples/core/preload/src/templates/mod.rs b/examples/core/preload/src/templates/mod.rs new file mode 100644 index 0000000000..9b9cf18fc5 --- /dev/null +++ b/examples/core/preload/src/templates/mod.rs @@ -0,0 +1,2 @@ +pub mod about; +pub mod index; diff --git a/examples/demos/fetching/src/templates/index.rs b/examples/demos/fetching/src/templates/index.rs index 319e21dc78..8c947d70fa 100644 --- a/examples/demos/fetching/src/templates/index.rs +++ b/examples/demos/fetching/src/templates/index.rs @@ -22,7 +22,7 @@ pub fn index_page<'a, G: Html>( #[cfg(target_arch = "wasm32")] // Because we only have `reqwasm` on the client-side, we make sure this is only *compiled* in // the browser as well - if G::IS_BROWSER && browser_ip.get().is_none() { + if browser_ip.get().is_none() { // Spawn a `Future` on this thread to fetch the data (`spawn_local` is // re-exported from `wasm-bindgen-futures`) Don't worry, this doesn't // need to be sent to JavaScript for execution diff --git a/packages/perseus-macro/src/template_rx.rs b/packages/perseus-macro/src/template_rx.rs index 9c877e26c3..6633230803 100644 --- a/packages/perseus-macro/src/template_rx.rs +++ b/packages/perseus-macro/src/template_rx.rs @@ -202,6 +202,9 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { #block } + // Declare that this page will never take any state to enable full caching + render_ctx.register_page_no_state(&props.path); + #component_name(cx) } }, @@ -241,6 +244,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { let render_ctx = ::perseus::get_render_ctx!(cx); // The render context will automatically handle prioritizing frozen or active state for us for this page as long as we have a reactive state type, which we do! match render_ctx.get_active_or_frozen_page_state::<#rx_props_ty>(&props.path) { + // If we navigated back to this page, and it's still in the PSS, the given state will be a dummy, but we don't need to worry because it's never checked if this evaluates ::std::option::Option::Some(existing_state) => existing_state, // Again, frozen state has been dealt with already, so we'll fall back to generated state ::std::option::Option::None => { @@ -286,6 +290,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { let render_ctx = ::perseus::get_render_ctx!(cx); // The render context will automatically handle prioritizing frozen or active state for us for this page as long as we have a reactive state type, which we do! match render_ctx.get_active_or_frozen_page_state::<#rx_props_ty>(&props.path) { + // If we navigated back to this page, and it's still in the PSS, the given state will be a dummy, but we don't need to worry because it's never checked if this evaluates ::std::option::Option::Some(existing_state) => existing_state, // Again, frozen state has been dealt with already, so we'll fall back to generated state ::std::option::Option::None => { @@ -316,6 +321,10 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { #block } + // Declare that this page will never take any state to enable full caching + let render_ctx = ::perseus::get_render_ctx!(cx); + render_ctx.register_page_no_state(&props.path); + #component_name(cx) } } diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index 5075a0c53f..554e28dbe8 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -49,7 +49,7 @@ wasm-bindgen-futures = "0.4" [features] # Live reloading will only take effect in development, and won't impact production # BUG This adds 1.9kB to the production bundle (that's without size optimizations though) -default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine", "minify", "minify-css" ] +default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine", "minify", "minify-css", "cache-initial-load" ] translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"] translator-lightweight = [] # This feature adds support for a number of macros that will make your life MUCH easier (read: use this unless you have very specific needs or are completely insane) @@ -63,6 +63,10 @@ client-helpers = [ "console_error_panic_hook" ] minify = [] minify-js = [ "minify" ] minify-css = [ "minify" ] +# This feature enables caching of pages that are loaded through the initial loads system (i.e. the first one the user goes to on your site); this involves making a +# (usually excellent) guess at the contents of the `` on that page. If you perform any advanced manipulation of the `` such that loading a page from +# scratch, going somewhere else, and then going back to it breaks something, disable this. +cache-initial-load = [] # This feature enables Sycamore hydration by default (Sycamore hydration feature is always activated though) # This is not enabled by default due to some remaining bugs (also, default features in Perseus can't be disabled without altering `.perseus/`) hydrate = [] diff --git a/packages/perseus/src/client.rs b/packages/perseus/src/client.rs index c4578db0e1..fceadb28c4 100644 --- a/packages/perseus/src/client.rs +++ b/packages/perseus/src/client.rs @@ -51,6 +51,7 @@ pub fn run_client( error_pages: app.get_error_pages(), templates: app.get_templates_map(), render_cfg: get_render_cfg().expect("render configuration invalid or not injected"), + pss_max_size: app.get_pss_max_size(), }; // At this point, the user can already see something from the server-side diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 2a12054d95..4784c5b3e0 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -76,6 +76,10 @@ pub enum ClientError { #[source] source: serde_json::Error, }, + #[error("the given path for preloading leads to a locale detection page; you probably wanted to wrap the path in `link!(...)`")] + PreloadLocaleDetection, + #[error("the given path for preloading was not found")] + PreloadNotFound, } /// Errors that can occur in the build process or while the server is running. @@ -177,6 +181,9 @@ pub enum FetchError { #[source] source: Box, }, + // This is not used by the `fetch` function, but it is used by the preloading system + #[error("asset not found")] + NotFound { url: String }, } /// Errors that can occur while building an app. diff --git a/packages/perseus/src/init.rs b/packages/perseus/src/init.rs index 53c5ad3afe..afa26d7da1 100644 --- a/packages/perseus/src/init.rs +++ b/packages/perseus/src/init.rs @@ -39,6 +39,13 @@ static DFLT_INDEX_VIEW: &str = r#"
"#; +/// The default number of pages the page state store will allow before evicting +/// the oldest. Note: we don't allow an infinite number in development here +/// because that does actually get quite problematic after a few hours of +/// constant reloading and HSR (as in Firefox decides that opening the DevTools +/// is no longer allowed). +// TODO What's a sensible value here? +static DFLT_PSS_MAX_SIZE: usize = 25; // This is broken out for debug implementation ease struct TemplateGetters(Vec Template>>); @@ -110,6 +117,8 @@ pub struct PerseusAppBase { template_getters: TemplateGetters, /// The app's error pages. error_pages: ErrorPagesGetter, + /// The maximum size for the page state store. + pss_max_size: usize, /// The global state creator for the app. // This is wrapped in an `Arc` so we can pass it around on the engine-side (which is solely for // Actix's benefit...) @@ -272,6 +281,7 @@ impl PerseusAppBase { // We do offer default error pages, but they'll panic if they're called for production // building error_pages: ErrorPagesGetter(Box::new(ErrorPages::default)), + pss_max_size: DFLT_PSS_MAX_SIZE, #[cfg(not(target_arch = "wasm32"))] global_state_creator: Arc::new(GlobalStateCreator::default()), // By default, we'll disable i18n (as much as I may want more websites to support more @@ -313,6 +323,7 @@ impl PerseusAppBase { // We do offer default error pages, but they'll panic if they're called for production // building error_pages: ErrorPagesGetter(Box::new(ErrorPages::default)), + pss_max_size: DFLT_PSS_MAX_SIZE, // By default, we'll disable i18n (as much as I may want more websites to support more // languages...) locales: Locales { @@ -535,7 +546,23 @@ impl PerseusAppBase { self } - // Setters + /// Sets the maximum number of pages that can have their states stored in + /// the page state store before the oldest will be evicted. If your app is + /// taking up a substantial amount of memory in the browser because your + /// page states are fairly large, making this smaller may help. + /// + /// By default, this is set to 25. Higher values may lead to memory + /// difficulties in both development and production, and the poor user + /// experience of a browser that's substantially slowed down. + /// + /// WARNING: any setting applied here will impact HSR in development! (E.g. + /// setting this to 1 would mean your position would only be + /// saved for the most recent page.) + pub fn pss_max_size(mut self, val: usize) -> Self { + self.pss_max_size = val; + self + } + // Getters /// Gets the HTML ID of the `
` at which to insert Perseus. pub fn get_root(&self) -> String { self.plugins @@ -738,6 +765,11 @@ impl PerseusAppBase { error_pages } + /// Gets the maximum number of pages that can be stored in the page state + /// store before the oldest are evicted. + pub fn get_pss_max_size(&self) -> usize { + self.pss_max_size + } /// Gets the [`GlobalStateCreator`]. This can't be directly modified by /// plugins because of reactive type complexities. #[cfg(not(target_arch = "wasm32"))] diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 25e78a59d1..a82ec0c030 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -71,6 +71,8 @@ pub use http; #[cfg(not(target_arch = "wasm32"))] pub use http::Request as HttpRequest; pub use sycamore_futures::spawn_local_scoped; +#[cfg(target_arch = "wasm32")] +pub use wasm_bindgen_futures::spawn_local; /// All HTTP requests use empty bodies for simplicity of passing them around. /// They'll never need payloads (value in path requested). #[cfg(not(target_arch = "wasm32"))] diff --git a/packages/perseus/src/router/app_route.rs b/packages/perseus/src/router/app_route.rs index df32d10174..a9499d281e 100644 --- a/packages/perseus/src/router/app_route.rs +++ b/packages/perseus/src/router/app_route.rs @@ -1,56 +1,56 @@ use super::{match_route, RouteVerdict}; use crate::{ i18n::Locales, - template::{TemplateMap, TemplateNodeType}, + template::{RenderCtx, TemplateMap, TemplateNodeType}, }; use std::collections::HashMap; +use std::rc::Rc; +use sycamore::prelude::Scope; use sycamore_router::Route; /// The Perseus route system, which implements Sycamore `Route`, but adds /// additional data for Perseus' processing system. -pub(crate) struct PerseusRoute { +pub(crate) struct PerseusRoute<'cx> { /// The current route verdict. The initialization value of this is /// completely irrelevant (it will be overridden immediately by the internal /// routing logic). pub verdict: RouteVerdict, - /// The app's render configuration. - pub render_cfg: HashMap, - /// The templates the app is using. - pub templates: TemplateMap, - /// The app's i18n configuration. - pub locales: Locales, + /// The Sycamore scope that allows us to access the render context. + /// + /// This will *always* be `Some(_)` in actual applications. + pub cx: Option>, } // Sycamore would only use this if we were processing dynamic routes, which -// we're not In other words, it's fine that these values would break everything +// we're not +// In other words, it's fine that these values would break everything // if they were used, they're just compiler appeasement -impl Default for PerseusRoute { +impl<'cx> Default for PerseusRoute<'cx> { fn default() -> Self { Self { verdict: RouteVerdict::NotFound, - render_cfg: HashMap::default(), - templates: TemplateMap::default(), - locales: Locales { - default: String::default(), - other: Vec::default(), - using_i18n: bool::default(), - }, + // Again, this will never be accessed + cx: None, } } } -impl PerseusRoute { +impl<'cx> PerseusRoute<'cx> { /// Gets the current route verdict. pub fn get_verdict(&self) -> &RouteVerdict { &self.verdict } } -impl Route for PerseusRoute { +impl<'cx> Route for PerseusRoute<'cx> { fn match_route(&self, path: &[&str]) -> Self { - let verdict = match_route(path, &self.render_cfg, &self.templates, &self.locales); + let render_ctx = RenderCtx::from_ctx(self.cx.unwrap()); // We know the scope will always exist + let verdict = match_route( + path, + &render_ctx.render_cfg, + &render_ctx.templates, + &render_ctx.locales, + ); Self { verdict, - render_cfg: self.render_cfg.clone(), - templates: self.templates.clone(), - locales: self.locales.clone(), + cx: self.cx, } } } diff --git a/packages/perseus/src/router/get_initial_view.rs b/packages/perseus/src/router/get_initial_view.rs index 9b49aada1b..1e34e9c233 100644 --- a/packages/perseus/src/router/get_initial_view.rs +++ b/packages/perseus/src/router/get_initial_view.rs @@ -1,38 +1,15 @@ use crate::error_pages::ErrorPageData; use crate::errors::*; -use crate::i18n::{detect_locale, ClientTranslationsManager, Locales}; +use crate::i18n::detect_locale; use crate::router::match_route; -use crate::router::{RouteInfo, RouteVerdict, RouterLoadState, RouterState}; -use crate::template::{PageProps, TemplateMap, TemplateNodeType}; +use crate::router::{RouteInfo, RouteVerdict, RouterLoadState}; +use crate::template::{PageProps, RenderCtx, TemplateNodeType}; use crate::utils::checkpoint; -use crate::ErrorPages; use fmterr::fmt_err; -use std::collections::HashMap; use sycamore::prelude::*; use sycamore::rt::Reflect; // We can piggyback off Sycamore to avoid bringing in `js_sys` -use wasm_bindgen::JsValue; - -pub(crate) struct GetInitialViewProps<'a, 'cx> { - /// The app's reactive scope. - pub cx: Scope<'cx>, - /// The path we're rendering for (not the template path, the full path, - /// though parsed a little). - pub path: String, - /// The router state. - pub router_state: RouterState, - /// A *client-side* translations manager to use (this manages caching - /// translations). - pub translations_manager: &'a ClientTranslationsManager, - /// The error pages, for use if something fails. - pub error_pages: &'a ErrorPages, - /// The locales settings the app is using. - pub locales: &'a Locales, - /// The templates the app is using. - pub templates: &'a TemplateMap, - /// The render configuration of the app (which lays out routing information, - /// among other things). - pub render_cfg: &'a HashMap, -} +use wasm_bindgen::{JsCast, JsValue}; +use web_sys::Element; /// Gets the initial view that we should display when the app first loads. This /// doesn't need to be asynchronous, since initial loads provide everything @@ -47,17 +24,18 @@ pub(crate) struct GetInitialViewProps<'a, 'cx> { /// returns, meaning that any errors that may occur after this function has been /// called need to reset the router state to be an error. pub(crate) fn get_initial_view( - GetInitialViewProps { - cx, - path, - mut router_state, - translations_manager, - error_pages, - locales, - templates, - render_cfg, - }: GetInitialViewProps<'_, '_>, + cx: Scope, + path: String, // The full path, not the template path (but parsed a little) ) -> InitialView { + let render_ctx = RenderCtx::from_ctx(cx); + let router_state = &render_ctx.router; + let translations_manager = &render_ctx.translations_manager; + let locales = &render_ctx.locales; + let templates = &render_ctx.templates; + let render_cfg = &render_ctx.render_cfg; + let error_pages = &render_ctx.error_pages; + let pss = &render_ctx.page_state_store; + // Start by figuring out what template we should be rendering let path_segments = path .split('/') @@ -140,13 +118,20 @@ pub(crate) fn get_initial_view( } }; + #[cfg(feature = "cache-initial-load")] + { + // Cache the page's head in the PSS (getting it as realiably as we can) + let head_str = get_head(); + pss.add_head(&path, head_str); + } + let path = template.get_path(); let page_props = PageProps { path: path_with_locale.clone(), state, global_state, }; - // Pre-emptively declare the page interactive 9since all we do from this point + // Pre-emptively declare the page interactive since all we do from this point // is hydrate checkpoint("page_interactive"); // Update the router state @@ -231,7 +216,7 @@ pub(crate) enum InitialView { /// isn't an initial load, and we need to request the page from the server. It /// could also be an error that the server has rendered. #[derive(Debug)] -pub(crate) enum InitialState { +enum InitialState { /// A non-error initial state has been injected. This could be `None`, since /// not all pages have state. Present(Option), @@ -245,7 +230,7 @@ pub(crate) enum InitialState { /// Gets the initial state injected by the server, if there was any. This is /// used to differentiate initial loads from subsequent ones, which have /// different log chains to prevent double-trips (a common SPA problem). -pub(crate) fn get_initial_state() -> InitialState { +fn get_initial_state() -> InitialState { let val_opt = web_sys::window().unwrap().get("__PERSEUS_INITIAL_STATE"); let js_obj = match val_opt { Some(js_obj) => js_obj, @@ -309,7 +294,7 @@ pub(crate) fn get_global_state() -> Option { /// Gets the translations injected by the server, if there was any. If there are /// errors in this, we can return `None` and not worry about it, they'll be /// handled by the initial state. -pub(crate) fn get_translations() -> Option { +fn get_translations() -> Option { let val_opt = web_sys::window().unwrap().get("__PERSEUS_TRANSLATIONS"); let js_obj = match val_opt { Some(js_obj) => js_obj, @@ -325,3 +310,38 @@ pub(crate) fn get_translations() -> Option { // have a dummy translator) Some(state_str) } + +/// Gets the entire contents of the current ``, up to the Perseus head-end +/// comment (which prevents any JS that was loaded later from being included). +/// This is not guaranteed to always get exactly the original head, but it's +/// pretty good, and prevents unnecessary network requests, while enabling the +/// caching of initially laoded pages. +#[cfg(feature = "cache-initial-load")] +fn get_head() -> String { + let document = web_sys::window().unwrap().document().unwrap(); + // Get the current head + let head_node = document.query_selector("head").unwrap().unwrap(); + // Get all the elements after the head boundary (otherwise we'd be duplicating + // the initial stuff) + let els = document + .query_selector_all(r#"meta[itemprop='__perseus_head_boundary'] ~ *"#) + .unwrap(); + // No, `NodeList` does not have an iterator implementation... + let mut head_vec = Vec::new(); + for i in 0..els.length() { + let elem: Element = els.get(i).unwrap().unchecked_into(); + // Check if this is the delimiter that denotes the end of the head (it's + // impossible for the user to add anything below here), since we don't + // want to get anything that other scripts might have added (but if that shows + // up, it shouldn't be catastrophic) + if elem.tag_name().to_lowercase() == "meta" + && elem.get_attribute("itemprop") == Some("__perseus_head_end".to_string()) + { + break; + } + let html = elem.outer_html(); + head_vec.push(html); + } + + head_vec.join("\n") +} diff --git a/packages/perseus/src/router/get_subsequent_view.rs b/packages/perseus/src/router/get_subsequent_view.rs index 2258175e11..290e5774dc 100644 --- a/packages/perseus/src/router/get_subsequent_view.rs +++ b/packages/perseus/src/router/get_subsequent_view.rs @@ -1,9 +1,8 @@ -use crate::error_pages::ErrorPages; use crate::errors::*; -use crate::i18n::ClientTranslationsManager; use crate::page_data::PageDataPartial; -use crate::router::{get_global_state, RouteVerdict, RouterLoadState, RouterState}; -use crate::template::{PageProps, Template, TemplateNodeType}; +use crate::router::{get_global_state, RouteVerdict, RouterLoadState}; +use crate::state::PssContains; +use crate::template::{PageProps, RenderCtx, Template, TemplateNodeType}; use crate::utils::checkpoint; use crate::utils::fetch; use crate::utils::get_path_prefix_client; @@ -26,13 +25,6 @@ pub(crate) struct GetSubsequentViewProps<'a> { pub was_incremental_match: bool, /// The locale we're rendering in. pub locale: String, - /// The router state. - pub router_state: RouterState, - /// A *client-side* translations manager to use (this manages caching - /// translations). - pub translations_manager: ClientTranslationsManager, - /// The error pages, for use if something fails. - pub error_pages: Rc>, /// The current route verdict. This will be stored in context so that it can /// be used for possible reloads. Eventually, this will be made obsolete /// when Sycamore supports this natively. @@ -57,12 +49,15 @@ pub(crate) async fn get_subsequent_view( template, was_incremental_match, locale, - mut router_state, - translations_manager, - error_pages, route_verdict, }: GetSubsequentViewProps<'_>, ) -> View { + let render_ctx = RenderCtx::from_ctx(cx); + let router_state = &render_ctx.router; + let translations_manager = &render_ctx.translations_manager; + let error_pages = &render_ctx.error_pages; + let pss = &render_ctx.page_state_store; + let path_with_locale = match locale.as_str() { "xx-XX" => path.clone(), locale => format!("{}/{}", locale, &path), @@ -75,105 +70,154 @@ pub(crate) async fn get_subsequent_view( router_state.set_last_verdict(route_verdict.clone()); checkpoint("initial_state_not_present"); - // If we're getting data about the index page, explicitly set it to that - // This can be handled by the Perseus server (and is), but not by static - // exporting - let path = match path.is_empty() { - true => "index".to_string(), - false => path, - }; - // Get the static page data - // TODO Only get the page state here - let asset_url = format!( - "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", - get_path_prefix_client(), - locale, - path, - template.get_path(), - was_incremental_match - ); - // If this doesn't exist, then it's a 404 (we went here by explicit navigation, - // but it may be an unservable ISR page or the like) - let page_data_str = fetch(&asset_url).await; - match &page_data_str { - Ok(page_data_str_opt) => match page_data_str_opt { - Some(page_data_str) => { - // All good, deserialize the page data - let page_data = serde_json::from_str::(&page_data_str); - match page_data { - Ok(page_data) => { - // Interpolate the metadata directly into the document's `` - replace_head(&page_data.head); - - // Now get the translator (this will be cached if the user hasn't switched - // locales) - let translator = translations_manager - .get_translator_for_locale(&locale) - .await; - let translator = match translator { - Ok(translator) => translator, + // Before we fetch anything, first check if there's an entry in the PSS already + // (if there is, we can avoid a network request) + let page_data: Result> = match pss.contains(&path) { + // We only have one part of the puzzle (or nothing at all), and no guarantee that the other + // doesn't exist, so we'll have to check with the server to be safe + PssContains::State | PssContains::Head | PssContains::None => { + // If we're getting data about the index page, explicitly set it to that + // This can be handled by the Perseus server (and is), but not by static + // exporting + let path_norm = match path.is_empty() { + true => "index".to_string(), + false => path.to_string(), + }; + // Get the static page data (head and state) + let asset_url = format!( + "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", + get_path_prefix_client(), + locale, + path_norm, + template.get_path(), + was_incremental_match + ); + // If this doesn't exist, then it's a 404 (we went here by explicit navigation, + // but it may be an unservable ISR page or the like) + let page_data_str = fetch(&asset_url).await; + match &page_data_str { + Ok(page_data_str_opt) => match page_data_str_opt { + Some(page_data_str) => { + // All good, deserialize the page data + let page_data = serde_json::from_str::(&page_data_str); + match page_data { + Ok(page_data) => { + // Add the head to the PSS for future use (we make absolutely no + // assumptions about state and leave that to the macros) + pss.add_head(&path, page_data.head.to_string()); + Ok(page_data) + } + // If the page failed to serialize, an exception has occurred Err(err) => { router_state.set_load_state(RouterLoadState::ErrorLoaded { path: path_with_locale.clone(), }); - match &err { - // These errors happen because we couldn't get a translator, so they certainly don't get one - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.get_view_and_render_head(cx, url, *status, &fmt_err(&err), None), - ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.get_view_and_render_head(cx, url, 500, &fmt_err(&err), None), - ClientError::LocaleNotSupported { locale } => return error_pages.get_view_and_render_head(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None), - // No other errors should be returned - _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") - } + panic!("page data couldn't be serialized: '{}'", err) } - }; - - let page_props = PageProps { - path: path_with_locale.clone(), - state: page_data.state, - // This will probably be overridden by the already-set version (unless no - // page has used global state yet) - global_state: get_global_state(), - }; - let template_name = template.get_path(); - // Pre-emptively update the router state - checkpoint("page_interactive"); - // Update the router state - router_state.set_load_state(RouterLoadState::Loaded { - template_name, - path: path_with_locale, - }); - // Now return the view that should be rendered - template.render_for_template_client(page_props, cx, translator) + } } - // If the page failed to serialize, an exception has occurred - Err(err) => { + // No translators ready yet + None => { router_state.set_load_state(RouterLoadState::ErrorLoaded { path: path_with_locale.clone(), }); - panic!("page data couldn't be serialized: '{}'", err) + Err(error_pages.get_view_and_render_head( + cx, + &asset_url, + 404, + "page not found", + None, + )) + } + }, + Err(err) => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + match &err { + // No translators ready yet + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => { + Err(error_pages.get_view_and_render_head( + cx, + url, + *status, + &fmt_err(&err), + None, + )) + } + // No other errors should be returned + _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), } } } - // No translators ready yet - None => { - router_state.set_load_state(RouterLoadState::ErrorLoaded { - path: path_with_locale.clone(), - }); - error_pages.get_view_and_render_head(cx, &asset_url, 404, "page not found", None) - } - }, + } + // We have everything locally, so we can move right ahead! + PssContains::All => Ok(PageDataPartial { + state: Some("PSS".to_string()), /* The macros will preferentially use the PSS state, + * so this will never be parsed */ + head: pss.get_head(&path).unwrap(), + }), + // We only have document metadata, but the page definitely takes no state, so we're fine + PssContains::HeadNoState => Ok(PageDataPartial { + state: None, + head: pss.get_head(&path).unwrap(), + }), + // The page's data has been preloaded at some other time + PssContains::Preloaded => { + let page_data = pss.get_preloaded(&path).unwrap(); + // Register the head, otherwise it will never be registered and the page will + // never properly show up in the PSS (meaning future preload + // calls will go through, creating unnecessary network requests) + pss.add_head(&path, page_data.head.to_string()); + Ok(page_data) + } + }; + // Any errors will be prepared error pages ready for return + let page_data = match page_data { + Ok(page_data) => page_data, + Err(view) => return view, + }; + + // Interpolate the metadata directly into the document's `` + replace_head(&page_data.head); + + // Now get the translator (this will be cached if the user hasn't switched + // locales) + let translator = translations_manager + .get_translator_for_locale(&locale) + .await; + let translator = match translator { + Ok(translator) => translator, Err(err) => { router_state.set_load_state(RouterLoadState::ErrorLoaded { path: path_with_locale.clone(), }); match &err { - // No translators ready yet - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => { - error_pages.get_view_and_render_head(cx, url, *status, &fmt_err(&err), None) - } + // These errors happen because we couldn't get a translator, so they certainly don't get one + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.get_view_and_render_head(cx, url, *status, &fmt_err(&err), None), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.get_view_and_render_head(cx, url, 500, &fmt_err(&err), None), + ClientError::LocaleNotSupported { locale } => return error_pages.get_view_and_render_head(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None), // No other errors should be returned - _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), + _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") } } - } + }; + + let page_props = PageProps { + path: path_with_locale.clone(), + state: page_data.state, + // This will probably be overridden by the already-set version (unless no + // page has used global state yet) + global_state: get_global_state(), + }; + let template_name = template.get_path(); + // Pre-emptively update the router state + checkpoint("page_interactive"); + // Update the router state + router_state.set_load_state(RouterLoadState::Loaded { + template_name, + path: path_with_locale, + }); + // Now return the view that should be rendered + template.render_for_template_client(page_props, cx, translator) } diff --git a/packages/perseus/src/router/mod.rs b/packages/perseus/src/router/mod.rs index be0e49c164..e1dc3c99ad 100644 --- a/packages/perseus/src/router/mod.rs +++ b/packages/perseus/src/router/mod.rs @@ -21,8 +21,6 @@ pub(crate) use router_component::{perseus_router, PerseusRouterProps}; pub use router_state::{RouterLoadState, RouterState}; #[cfg(target_arch = "wasm32")] -pub(crate) use get_initial_view::{ - get_global_state, get_initial_view, GetInitialViewProps, InitialView, -}; +pub(crate) use get_initial_view::{get_global_state, get_initial_view, InitialView}; #[cfg(target_arch = "wasm32")] pub(crate) use get_subsequent_view::{get_subsequent_view, GetSubsequentViewProps}; diff --git a/packages/perseus/src/router/router_component.rs b/packages/perseus/src/router/router_component.rs index 70f555b8d8..18ce2edc1b 100644 --- a/packages/perseus/src/router/router_component.rs +++ b/packages/perseus/src/router/router_component.rs @@ -1,10 +1,9 @@ use crate::{ checkpoint, + i18n::detect_locale, i18n::Locales, - i18n::{detect_locale, ClientTranslationsManager}, router::{ - get_initial_view, get_subsequent_view, GetInitialViewProps, GetSubsequentViewProps, - InitialView, RouterLoadState, RouterState, + get_initial_view, get_subsequent_view, GetSubsequentViewProps, InitialView, RouterLoadState, }, router::{PerseusRoute, RouteInfo, RouteVerdict}, template::{RenderCtx, TemplateMap, TemplateNodeType}, @@ -39,17 +38,6 @@ const ROUTE_ANNOUNCER_STYLES: &str = r#" word-wrap: normal; "#; -/// The properties that `get_view()` takes. See the shell properties for -/// the details for most of these. -#[derive(Clone)] -struct GetViewProps<'a> { - cx: Scope<'a>, - locales: Rc, - router_state: RouterState, - translations_manager: ClientTranslationsManager, - error_pages: Rc>, -} - /// Get the view we should be rendering at the moment, based on a route verdict. /// This should be called on every route change to update the page. This doesn't /// actually render the view, it just returns it for rendering. Note that this @@ -62,14 +50,8 @@ struct GetViewProps<'a> { /// If the page needs to redirect to a particular locale, then this function /// will imperatively do so. async fn get_view( + cx: Scope<'_>, verdict: RouteVerdict, - GetViewProps { - cx, - locales, - router_state, - translations_manager, - error_pages, - }: GetViewProps<'_>, ) -> View { checkpoint("router_entry"); match &verdict { @@ -85,16 +67,14 @@ async fn get_view( template: template.clone(), was_incremental_match: *was_incremental_match, locale: locale.clone(), - router_state, - translations_manager: translations_manager.clone(), - error_pages: error_pages.clone(), route_verdict: verdict, }) .await } // For subsequent loads, this should only be possible if the dev forgot `link!()` RouteVerdict::LocaleDetection(path) => { - let dest = detect_locale(path.clone(), &locales); + let render_ctx = RenderCtx::from_ctx(cx); + let dest = detect_locale(path.clone(), &render_ctx.locales); // Since this is only for subsequent loads, we know the router is instantiated // This shouldn't be a replacement navigation, since the user has deliberately // navigated here @@ -102,10 +82,13 @@ async fn get_view( View::empty() } RouteVerdict::NotFound => { + let render_ctx = RenderCtx::from_ctx(cx); checkpoint("not_found"); // TODO Update the router state here (we need a path though...) // This function only handles subsequent loads, so this is all we have - error_pages.get_view_and_render_head(cx, "", 404, "not found", None) + render_ctx + .error_pages + .get_view_and_render_head(cx, "", 404, "not found", None) } } } @@ -122,6 +105,9 @@ pub(crate) struct PerseusRouterProps { /// The render configuration of the app (which lays out routing information, /// among other things). pub render_cfg: HashMap, + /// The maximum size of the page state store, before pages are evicted + /// to save memory in the browser. + pub pss_max_size: usize, } /// The Perseus router. This is used internally in the Perseus engine, and you @@ -141,17 +127,20 @@ pub(crate) fn perseus_router( locales, templates, render_cfg, + pss_max_size, }: PerseusRouterProps, ) -> View { - let translations_manager = ClientTranslationsManager::new(&locales); - // Get the error pages in an `Rc` so we aren't creating hundreds of them - let error_pages = Rc::new(error_pages); // Now create an instance of `RenderCtx`, which we'll insert into context and - // use everywhere throughout the app - let render_ctx = RenderCtx::default().set_ctx(cx); - // TODO Replace passing a router state around with getting it out of context - // instead in the shell - let router_state = &render_ctx.router; // We need this for interfacing with the router though + // use everywhere throughout the app (this contains basically everything Perseus + // needs in terms of infrastructure) + let render_ctx = RenderCtx::new( + pss_max_size, + locales, // Pretty light + templates, // Already has `Rc`s + Rc::new(render_cfg), + Rc::new(error_pages), + ) + .set_ctx(cx); // Get the current path, removing any base paths to avoid relative path locale // redirection loops (in previous versions of Perseus, we used Sycamore to @@ -168,17 +157,7 @@ pub(crate) fn perseus_router( }; // Prepare the initial view for hydration (because we have everything we need in // global window variables, this can be synchronous) - let initial_view = get_initial_view(GetInitialViewProps { - cx, - // Get the path directly, in the same way the Sycamore router's history integration does - path: path.to_string(), - router_state: router_state.clone(), - translations_manager: &translations_manager, - error_pages: &error_pages, - locales: &locales, - templates: &templates, - render_cfg: &render_cfg, - }); + let initial_view = get_initial_view(cx, path.to_string()); let initial_view = match initial_view { InitialView::View(initial_view) => initial_view, // if we need to redirect, then we'll create a fake view that will just execute that code @@ -205,14 +184,9 @@ pub(crate) fn perseus_router( // Create a `Route` to pass through Sycamore with the information we need let route = PerseusRoute { verdict: RouteVerdict::NotFound, - templates, - render_cfg, - locales: locales.clone(), + cx: Some(cx), }; - // Now that we've used the reference, put the locales in an `Rc` - let locales = Rc::new(locales); - // Create a derived state for the route announcement // We do this with an effect because we only want to update in some cases (when // the new page is actually loaded) We also need to know if it's the first @@ -221,7 +195,7 @@ pub(crate) fn perseus_router( let route_announcement = create_signal(cx, String::new()); let mut is_first_page = true; // This is different from the first page load (this is the first page as a // whole) - let load_state = router_state.get_load_state_rc(); + let load_state = render_ctx.router.get_load_state_rc(); create_effect(cx, move || { if let RouterLoadState::Loaded { path, .. } = &*load_state.get() { if is_first_page { @@ -270,18 +244,8 @@ pub(crate) fn perseus_router( } }); - // Set up the function we'll call on a route change - // Set up the properties for the function we'll call in a route change - let get_view_props = GetViewProps { - cx, - locales, - router_state: router_state.clone(), - translations_manager, - error_pages, - }; - // Listen for changes to the reload commander and reload as appropriate - let gvp = get_view_props.clone(); + let router_state = &render_ctx.router; create_effect(cx, move || { router_state.reload_commander.track(); // Using a tracker of the initial state separate to the main one is fine, @@ -299,11 +263,8 @@ pub(crate) fn perseus_router( // If the first page hasn't loaded yet, terminate now None => return, }; - // Because of the way futures work, a double clone is unfortunately necessary - // for now - let gvp = gvp.clone(); spawn_local_scoped(cx, async move { - let new_view = get_view(verdict.clone(), gvp).await; + let new_view = get_view(cx, verdict.clone()).await; curr_view.set(new_view); }); } @@ -390,11 +351,10 @@ pub(crate) fn perseus_router( if *is_initial.get_untracked() { is_initial.set(false); } else { - let gvp = get_view_props.clone(); spawn_local_scoped(cx, async move { let route = route.get(); let verdict = route.get_verdict(); - let new_view = get_view(verdict.clone(), gvp).await; + let new_view = get_view(cx, verdict.clone()).await; curr_view.set(new_view); }); } diff --git a/packages/perseus/src/router/router_state.rs b/packages/perseus/src/router/router_state.rs index 180af2e558..253e6e129f 100644 --- a/packages/perseus/src/router/router_state.rs +++ b/packages/perseus/src/router/router_state.rs @@ -57,7 +57,7 @@ impl RouterState { (*self.last_verdict.borrow()).clone() } /// Sets the last verdict. - pub fn set_last_verdict(&mut self, new: RouteVerdict) { + pub fn set_last_verdict(&self, new: RouteVerdict) { let mut last_verdict = self.last_verdict.borrow_mut(); *last_verdict = Some(new); } diff --git a/packages/perseus/src/server/html_shell.rs b/packages/perseus/src/server/html_shell.rs index 80bfc7d0b2..255974ea65 100644 --- a/packages/perseus/src/server/html_shell.rs +++ b/packages/perseus/src/server/html_shell.rs @@ -302,12 +302,16 @@ impl fmt::Display for HtmlShell { let head_start = self.head_before_boundary.join("\n"); // We also inject a delimiter dummy `` tag that will be used to wall off // the constant document head from the interpolated document head + // There's another one of these for denoting the end so we can semi-reliably + // extract the head of initially loaded pages for PSS caching let head_end = format!( r#" {head_after_boundary} + + "#, scripts_before_boundary = self.scripts_before_boundary.join("\n"), head_after_boundary = self.head_after_boundary.join("\n"), diff --git a/packages/perseus/src/state/mod.rs b/packages/perseus/src/state/mod.rs index e6e4f14b01..32bb60bd2e 100644 --- a/packages/perseus/src/state/mod.rs +++ b/packages/perseus/src/state/mod.rs @@ -5,7 +5,7 @@ mod rx_state; pub use freeze::{FrozenApp, PageThawPrefs, ThawPrefs}; pub use global_state::{GlobalState, GlobalStateCreator}; -pub use page_state_store::PageStateStore; +pub use page_state_store::{PageStateStore, PssContains, PssEntry, PssState}; pub use rx_state::{AnyFreeze, Freeze, MakeRx, MakeUnrx}; #[cfg(all(feature = "idb-freezing", target_arch = "wasm32"))] diff --git a/packages/perseus/src/state/page_state_store.rs b/packages/perseus/src/state/page_state_store.rs index c8f54bd686..6c7b883906 100644 --- a/packages/perseus/src/state/page_state_store.rs +++ b/packages/perseus/src/state/page_state_store.rs @@ -1,3 +1,4 @@ +use crate::page_data::PageDataPartial; use crate::state::AnyFreeze; use std::cell::RefCell; use std::collections::HashMap; @@ -14,47 +15,359 @@ use std::rc::Rc; /// here. If you need to store state for a page across locales, you should use /// the global state system instead. For apps not using i18n, the page URL will /// not include any locale. -#[derive(Default, Clone)] +// WARNING: Never allow users to manually modify the internal maps/orderings of this, +// or the eviction protocols will become very confused! +#[derive(Clone)] pub struct PageStateStore { /// A map of type IDs to anything, allowing one storage of each type (each /// type is intended to a properties `struct` for a template). Entries must /// be `Clone`able because we assume them to be `Signal`s or `struct`s /// composed of `Signal`s. + /// + /// This also stores the head string for each page, which means we don't + /// need to re-request old pages from the server whatsoever, minimizing + /// requests. // Technically, this should be `Any + Clone`, but that's not possible without something like // `dyn_clone`, and we don't need it because we can restrict on the methods instead! - map: Rc>>>, + map: Rc>>, + /// The order in which pages were submitted to the store. This is used to + /// evict the state of old pages to prevent Perseus sites from becoming + /// massive in the browser's memory and slowing the user's browser down. + order: Rc>>, + /// The maximum size of the store before pages are evicted, specified in + /// terms of a number of pages. Note that this pays no attention to the + /// size in memory of individual pages (which should be dropped manually + /// if this is a concern). + /// + /// Note: whatever you set here will impact HSR. + max_size: usize, + /// A list of pages that will be kept in the store no matter what. This can + /// be used to maintain the states of essential pages regardless of how + /// much the user has travelled through the site. The *vast* majority of + /// use-cases for this would be better fulfilled by using global state, and + /// this API is *highly* likely to be misused! If at all possible, use + /// global state! + keep_list: Rc>>, + /// A list of pages whose data have been manually preloaded to minimize + /// future network requests. This list is intended for pages that are to + /// be globally preloaded; any pages that should only be preloaded for a + /// specific route should be placed in `route_preloaded` instead. + preloaded: Rc>>, + /// Pages that have been prelaoded for the current route, which should be + /// cleared on a route change. + route_preloaded: Rc>>, } impl PageStateStore { + /// Creates a new, empty page state store with the given maximum size. After + /// this number of pages have been entered, the oldest ones will have + /// their states eliminated. Note that individual pages can be + /// marked for keeping or can be manually removed to circumvent these + /// mechanisms. + pub fn new(max_size: usize) -> Self { + Self { + map: Rc::default(), + order: Rc::default(), + max_size, + keep_list: Rc::default(), + preloaded: Rc::default(), + route_preloaded: Rc::default(), + } + } /// Gets an element out of the state by its type and URL. If the element /// stored for the given URL doesn't match the provided type, `None` will be /// returned. - pub fn get(&self, url: &str) -> Option { + /// + /// This will NOT return any document metadata, if any exists. + pub fn get_state(&self, url: &str) -> Option { + let map = self.map.borrow(); + match map.get(url) { + Some(entry) => { + let state = match &entry.state { + PssState::Some(state) => state, + // We don't care whether there could be state in future, there isn't any right + // now + _ => return None, + }; + state.as_any().downcast_ref::().map(|val| (*val).clone()) + } + None => None, + } + } + /// Gets the document metadata registered for a URL, if it exists. + pub fn get_head(&self, url: &str) -> Option { let map = self.map.borrow(); - map.get(url) - .and_then(|val| val.as_any().downcast_ref::().map(|val| (*val).clone())) + match map.get(url) { + Some(entry) => entry.head.as_ref().map(|v| v.to_string()), + None => None, + } } - /// Adds a new element to the state by its URL. Any existing element with - /// the same URL will be silently overridden (use `.contains()` to check - /// first if needed). - pub fn add(&self, url: &str, val: T) { + /// Adds page state to the entry in the store with the given URL, creating + /// it if it doesn't exist. Any state previously set for the item will + /// be overridden, but any document metadata will be preserved. + /// + /// This will be added to the end of the `order` property, and any previous + /// entries of it in that list will be removed. + /// + /// If there's already an entry for the given URL that has been marked as + /// not accepting state, this will return `false`, and the entry will + /// not be added. This *must* be handled for correctness. + #[must_use] + pub fn add_state(&self, url: &str, val: T) -> bool { let mut map = self.map.borrow_mut(); - map.insert(url.to_string(), Box::new(val)); + // We want to modify any existing entries to avoid wiping out document metadata + if let Some(entry) = map.get_mut(url) { + if !entry.set_state(Box::new(val)) { + return false; + } + } else { + let mut new_entry = PssEntry::default(); + if !new_entry.set_state(Box::new(val)) { + return false; + } + map.insert(url.to_string(), new_entry); + } + let mut order = self.order.borrow_mut(); + // If we haven't been told to keep this page, enter it in the order list so it + // can be evicted later + if !self.keep_list.borrow().iter().any(|x| x == url) { + // Get rid of any previous mentions of this page in the order list + order.retain(|stored_url| stored_url != url); + order.push(url.to_string()); + // If we've used up the maximum size yet, we should get rid of the oldest pages + if order.len() > self.max_size { + // Because this is called on every addition, we can safely assume that it's only + // one over + let old_url = order.remove(0); + map.remove(&old_url); // This will only occur for pages that + // aren't in the keep list, since those + // don't even appear in `order` + } + } + // If we got to here, then there were no issues with not accepting state + true + } + /// Adds document metadata to the entry in the store for the given URL, + /// creating it if it doesn't exist. + /// + /// This will be added to the end of the `order` property, and any previous + /// entries of it in that list will be removed. + pub fn add_head(&self, url: &str, head: String) { + let mut map = self.map.borrow_mut(); + // We want to modify any existing entries to avoid wiping out state + if let Some(entry) = map.get_mut(url) { + entry.set_head(head); + } else { + let mut new_entry = PssEntry::default(); + new_entry.set_head(head); + map.insert(url.to_string(), new_entry); + } + let mut order = self.order.borrow_mut(); + // If we haven't been told to keep this page, enter it in the order list so it + // can be evicted later + if !self.keep_list.borrow().iter().any(|x| x == url) { + // Get rid of any previous mentions of this page in the order list + order.retain(|stored_url| stored_url != url); + order.push(url.to_string()); + // If we've used up the maximum size yet, we should get rid of the oldest pages + if order.len() > self.max_size { + // Because this is called on every addition, we can safely assume that it's only + // one over + let old_url = order.remove(0); + map.remove(&old_url); // This will only occur for pages that + // aren't in the keep list, since those + // don't even appear in `order` + } + } + } + /// Sets the given entry as not being able to take any state. Any future + /// attempt to register state for it will lead to silent failures and/or + /// panics. + pub fn set_state_never(&self, url: &str) { + let mut map = self.map.borrow_mut(); + // If there's no entry for this URl yet, we'll create it + if let Some(entry) = map.get_mut(url) { + entry.set_state_never(); + } else { + let mut new_entry = PssEntry::default(); + new_entry.set_state_never(); + map.insert(url.to_string(), new_entry); + } } /// Checks if the state contains an entry for the given URL. - pub fn contains(&self, url: &str) -> bool { - self.map.borrow().contains_key(url) + pub fn contains(&self, url: &str) -> PssContains { + let map = self.map.borrow(); + let contains = match map.get(url) { + Some(entry) => match entry.state { + PssState::Some(_) => match entry.head { + Some(_) => PssContains::All, + None => PssContains::State, + }, + PssState::None => match entry.head { + Some(_) => PssContains::Head, + None => PssContains::None, + }, + PssState::Never => match entry.head { + Some(_) => PssContains::HeadNoState, + None => PssContains::None, + }, + }, + None => PssContains::None, + }; + // Now do a final check to make sure it hasn't been preloaded (which would show + // up as `PssContains::None` after that) + match contains { + PssContains::None | PssContains::Head | PssContains::State => { + let preloaded = self.preloaded.borrow(); + let route_preloaded = self.route_preloaded.borrow(); + // We don't currently distinguish between *how* the page has been preloaded + if route_preloaded.contains_key(url) || preloaded.contains_key(url) { + PssContains::Preloaded + } else { + contains + } + } + _ => contains, + } + } + /// Preloads the given URL from the server and adds it to the PSS. + /// + /// This function has no effect on the server-side. + /// + /// Note that this should generally be called through `RenderCtx`, to avoid + /// having to manually collect the required arguments. + // Note that this function bears striking resemblance to the subsequent load system! + #[cfg(target_arch = "wasm32")] + pub(crate) async fn preload( + &self, + path: &str, + locale: &str, + template_path: &str, + was_incremental_match: bool, + is_route_preload: bool, + ) -> Result<(), crate::errors::ClientError> { + use crate::{ + errors::FetchError, + utils::{fetch, get_path_prefix_client}, + }; + + // If we already have the page loaded fully in the PSS, abort immediately + if let PssContains::All | PssContains::HeadNoState | PssContains::Preloaded = + self.contains(path) + { + return Ok(()); + } + + // If we're getting data about the index page, explicitly set it to that + // This can be handled by the Perseus server (and is), but not by static + // exporting + let path_norm = match path.is_empty() { + true => "index".to_string(), + false => path.to_string(), + }; + // Get the static page data (head and state) + let asset_url = format!( + "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", + get_path_prefix_client(), + locale, + path_norm, + template_path, + was_incremental_match + ); + // If this doesn't exist, then it's a 404 (we went here by explicit instruction, + // but it may be an unservable ISR page or the like) + let page_data_str = fetch(&asset_url).await?; + match page_data_str { + Some(page_data_str) => { + // All good, deserialize the page data + let page_data = + serde_json::from_str::(&page_data_str).map_err(|err| { + FetchError::SerFailed { + url: path.to_string(), + source: err.into(), + } + })?; + let mut preloaded = if is_route_preload { + self.preloaded.borrow_mut() + } else { + self.route_preloaded.borrow_mut() + }; + preloaded.insert(path.to_string(), page_data); + Ok(()) + } + None => Err(FetchError::NotFound { + url: path.to_string(), + } + .into()), + } + } + /// Gets a preloaded page. This will search both the globally and + /// route-specifically preloaded pages. + /// + /// Note that this will delete the preloaded page from the preload cache, + /// since it's expected to be parsed and rendered immediately. It should + /// also have its head entered in the PSS. + pub fn get_preloaded(&self, url: &str) -> Option { + let mut preloaded = self.preloaded.borrow_mut(); + let mut route_preloaded = self.route_preloaded.borrow_mut(); + if let Some(page_data) = preloaded.remove(url) { + Some(page_data) + } else { + route_preloaded.remove(url) + } + } + /// Clears all the routes that were preloaded for the last route, keeping + /// only those listed (this should be used to make sure we don't have to + /// double-preload things). + pub fn cycle_route_preloaded(&self, keep_urls: &[&str]) { + let mut preloaded = self.route_preloaded.borrow_mut(); + preloaded.retain(|url, _| keep_urls.iter().any(|keep_url| keep_url == url)); + } + /// Forces the store to keep a certain page. This will prevent it from being + /// evicted from the store, regardless of how many other pages are + /// entered after it. + /// + /// Warning: in the *vast* majority of cases, your use-case for this will be + /// far better served by the global state system! (If you use this with + /// mutable state, you are quite likely to shoot yourself in the foot.) + pub fn force_keep(&self, url: &str) { + let mut order = self.order.borrow_mut(); + // Get rid of any previous mentions of this page in the order list (which will + // prevent this page from ever being evicted) + order.retain(|stored_url| stored_url != url); + let mut keep_list = self.keep_list.borrow_mut(); + keep_list.push(url.to_string()); + } + /// Forcibly removes a page from the store. Generally, you should never need + /// to use this function, but it's provided for completeness. This could + /// be used for preventing a certain page from being frozen, + /// if necessary. Note that calling this in development will cause HSR to + /// not work (since it relies on the state freezing system). + /// + /// This returns the page's state, if it was found. + pub fn force_remove(&self, url: &str) -> Option { + let mut order = self.order.borrow_mut(); + order.retain(|stored_url| stored_url != url); + let mut map = self.map.borrow_mut(); + map.remove(url) } } impl PageStateStore { /// Freezes the component entries into a new `HashMap` of `String`s to avoid - /// extra layers of deserialization. + /// extra layers of deserialization. This does NOT include document + /// metadata, which will be re-requested from the server. (There is no + /// point in freezing that, since it can't be unique for the user's page + /// interactions, as it's added directly as the server sends it.) // TODO Avoid literally cloning all the page states here if possible pub fn freeze_to_hash_map(&self) -> HashMap { let map = self.map.borrow(); let mut str_map = HashMap::new(); - for (k, v) in map.iter() { - let v_str = v.freeze(); - str_map.insert(k.to_string(), v_str); + for (k, entry) in map.iter() { + // Only freeze the underlying state if there is any (we want to minimize space + // usage) + if let PssState::Some(state) = &entry.state { + let v_str = state.freeze(); + str_map.insert(k.to_string(), v_str); + } } str_map @@ -66,71 +379,89 @@ impl std::fmt::Debug for PageStateStore { } } -// TODO Use `trybuild` properly with all this -//// These are tests for the `#[make_rx]` proc macro (here temporarily) -// #[cfg(test)] -// mod tests { -// use serde::{Deserialize, Serialize}; -// use crate::state::MakeRx; // We need this to manually use `.make_rx()` - -// #[test] -// fn named_fields() { -// #[perseus_macro::make_rx(TestRx)] -// struct Test { -// foo: String, -// bar: u16, -// } - -// let new = Test { -// foo: "foo".to_string(), -// bar: 5, -// } -// .make_rx(); -// new.bar.set(6); -// } +/// An entry for a single page in the PSS. This type has no concern for the +/// actual type of the page state it stores. +/// +/// Note: while it is hypothetically possible for this to hold neither a state +/// nor document metadata, that will never happen without user intervention. +pub struct PssEntry { + /// The page state, if any exists. This may come with a guarantee that no + /// state will ever exist. + state: PssState>, + /// The document metadata of the page, which can be cached to prevent future + /// requests to the server. + head: Option, +} +impl Default for PssEntry { + fn default() -> Self { + Self { + // There could be state later + state: PssState::None, + head: None, + } + } +} +impl PssEntry { + /// Declare that this entry will *never* have state. This should be done by + /// macros that definitively know the structure of a page. This action + /// is irrevocable, since a page cannot transition from never taking state + /// to taking some later in Perseus. + /// + /// Note that this will not be preserved in freezing (allowing greater + /// flexibility of HSR). + /// + /// **Warning:** manually calling in the wrong context this may lead to the + /// complete failure of your application! + pub fn set_state_never(&mut self) { + self.state = PssState::Never; + } + /// Adds document metadata to this entry. + pub fn set_head(&mut self, head: String) { + self.head = Some(head); + } + /// Adds state to this entry. This will return false and do nothing if the + /// entry has been marked as never being able to accept state. + #[must_use] + pub fn set_state(&mut self, state: Box) -> bool { + if let PssState::Never = self.state { + false + } else { + self.state = PssState::Some(state); + true + } + } +} -// #[test] -// fn nested() { -// #[perseus_macro::make_rx(TestRx)] -// // `Serialize`, `Deserialize`, and `Clone` are automatically derived -// #[rx::nested("nested", NestedRx)] -// struct Test { -// #[serde(rename = "foo_test")] -// foo: String, -// bar: u16, -// // This will get simple reactivity -// // This annotation is unnecessary though -// baz: Baz, -// // This will get fine-grained reactivity -// nested: Nested, -// } -// #[derive(Serialize, Deserialize, Clone)] -// struct Baz { -// test: String, -// } -// #[perseus_macro::make_rx(NestedRx)] -// struct Nested { -// test: String, -// } +/// The page state of a PSS entry. This is used to determine whether or not we +/// need to request data from the server. +pub enum PssState { + /// There is state. + Some(T), + /// There is no state, but there could be some in future. + None, + /// There is no state, and there never will be any (i.e. this page does not + /// use state). + Never, +} -// let new = Test { -// foo: "foo".to_string(), -// bar: 5, -// baz: Baz { -// // We won't be able to `.set()` this -// test: "test".to_string(), -// }, -// nested: Nested { -// // We will be able to `.set()` this -// test: "nested".to_string(), -// }, -// } -// .make_rx(); -// new.bar.set(6); -// new.baz.set(Baz { -// test: "updated".to_string(), -// }); -// new.nested.test.set("updated".to_string()); -// let _ = new.clone(); -// } -// } +/// The various things the PSS can contain for a single page. It might have +/// state, a head, both, or neither. +#[derive(Debug)] +pub enum PssContains { + /// There is no entry for this page. + None, + /// There is page state only recorded for this page. + State, + /// There is only document metadata recorded for this page. There is no + /// state recorded, but that doesn't mean the page has none. + Head, + /// There is document metadata recorded for this page, along with an + /// assurance that there will never be any state. + HeadNoState, + /// Both document metadata and page state are present for this page. + All, + /// We have a [`PageDataPartial`] for the given page, since it was preloaded + /// by some other function (likely the user's action). This will need proper + /// processing into a state. + Preloaded, +} diff --git a/packages/perseus/src/template/core.rs b/packages/perseus/src/template/core.rs index f4b4978686..fa46cd0d2a 100644 --- a/packages/perseus/src/template/core.rs +++ b/packages/perseus/src/template/core.rs @@ -299,6 +299,7 @@ impl Template { // The context we have here has no context elements set on it, so we set all the // defaults (job of the router component on the client-side) // We don't need the value, we just want the context instantiations + // We don't need any page state store here let _ = RenderCtx::default().set_ctx(cx); // And now provide a translator separately provide_context_signal_replace(cx, translator.clone()); @@ -525,7 +526,8 @@ impl Template { } /// Checks if this template defines no rendering logic whatsoever. Such /// templates will be rendered using SSG. Basic templates can - /// still modify headers. + /// still modify headers (which could hypothetically be using global state + /// that's dependent on server-side generation). #[cfg(not(target_arch = "wasm32"))] pub fn is_basic(&self) -> bool { !self.uses_build_paths() diff --git a/packages/perseus/src/template/render_ctx.rs b/packages/perseus/src/template/render_ctx.rs index 3d1dd099df..9726acddc8 100644 --- a/packages/perseus/src/template/render_ctx.rs +++ b/packages/perseus/src/template/render_ctx.rs @@ -1,9 +1,11 @@ +#[cfg(target_arch = "wasm32")] +use super::TemplateNodeType; use crate::errors::*; use crate::router::{RouterLoadState, RouterState}; use crate::state::{ AnyFreeze, Freeze, FrozenApp, GlobalState, MakeRx, MakeUnrx, PageStateStore, ThawPrefs, }; -use std::cell::{Cell, RefCell}; +use std::cell::RefCell; use std::rc::Rc; use sycamore::prelude::{provide_context, use_context, Scope}; use sycamore_router::navigate; @@ -39,22 +41,34 @@ pub struct RenderCtx { /// A previous state the app was once in, still serialized. This will be /// rehydrated gradually by the template macro. pub frozen_app: Rc>>, + /// The app's error pages. If you need to render an error, you should use + /// these! + /// + /// **Warning:** these don't exist on the engine-side! But, there, you + /// should always return a build-time error rather than produce a page + /// with an error in it. + #[cfg(target_arch = "wasm32")] + pub error_pages: Rc>, + // --- PRIVATE FIELDS --- + // Any users accessing these are *extremely* likely to shoot themselves in the foot! /// 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. - pub is_first: Rc>, -} -impl Default for RenderCtx { - fn default() -> Self { - Self { - router: RouterState::default(), - page_state_store: PageStateStore::default(), - global_state: GlobalState::default(), - frozen_app: Rc::new(RefCell::new(None)), - is_first: Rc::new(Cell::new(true)), - } - } + #[cfg(target_arch = "wasm32")] + pub(crate) is_first: Rc>, + /// The locales, for use in routing. + #[cfg(target_arch = "wasm32")] + pub(crate) locales: crate::i18n::Locales, + /// The map of all templates in the app, for use in routing. + #[cfg(target_arch = "wasm32")] + pub(crate) templates: crate::template::TemplateMap, + /// The render configuration, for use in routing. + #[cfg(target_arch = "wasm32")] + pub(crate) render_cfg: Rc>, + /// The client-side translations manager. + #[cfg(target_arch = "wasm32")] + pub(crate) translations_manager: crate::i18n::ClientTranslationsManager, } impl Freeze for RenderCtx { /// 'Freezes' the relevant parts of the render configuration to a serialized @@ -77,7 +91,45 @@ impl Freeze for RenderCtx { serde_json::to_string(&frozen_app).unwrap() } } +#[cfg(not(target_arch = "wasm32"))] // To prevent foot-shooting +impl Default for RenderCtx { + fn default() -> Self { + Self { + router: RouterState::default(), + page_state_store: PageStateStore::new(0), /* There will be no need for the PSS on the + * server-side */ + global_state: GlobalState::default(), + frozen_app: Rc::new(RefCell::new(None)), + } + } +} impl RenderCtx { + /// Creates a new instance of the render context, with the given maximum + /// size for the page state store, and other properties. + #[cfg(target_arch = "wasm32")] // To prevent foot-shooting + /// Note: this is designed for client-side usage, use `::default()` on the + /// engine-side. + pub(crate) fn new( + pss_max_size: usize, + locales: crate::i18n::Locales, + templates: crate::template::TemplateMap, + render_cfg: Rc>, + error_pages: Rc>, + ) -> Self { + let translations_manager = crate::i18n::ClientTranslationsManager::new(&locales); + Self { + router: RouterState::default(), + page_state_store: PageStateStore::new(pss_max_size), + global_state: GlobalState::default(), + frozen_app: Rc::new(RefCell::new(None)), + is_first: Rc::new(std::cell::Cell::new(true)), + error_pages, + locales, + templates, + render_cfg, + translations_manager, + } + } // TODO Use a custom, optimized context system instead of Sycamore's? (GIven we // only need to store one thing...) /// Gets an instance of `RenderCtx` out of Sycamore's context system. @@ -89,9 +141,112 @@ impl RenderCtx { /// have been added to context already (or Sycamore will cause a panic). /// Once this is done, the render context can be modified safely with /// interior mutability. - pub fn set_ctx(self, cx: Scope) -> &Self { + pub(crate) fn set_ctx(self, cx: Scope) -> &Self { provide_context(cx, self) } + /// Preloads the given URL from the server and caches it, preventing + /// future network requests to fetch that page. + /// + /// This function automatically defers the asynchronous preloading + /// work to a browser future for convenience. If you would like to + /// access the underlying future, use `.try_preload()` instead. + /// + /// # Panics + /// This function will panic if any errors occur in preloading, such as + /// the route being not found, or not localized. If the path you're + /// preloading is not hardcoded, use `.try_preload()` instead. + // Conveniently, we can use the lifetime mechanics of knowing that the render context + // is registered on the given scope to ensure that the future works out + #[cfg(target_arch = "wasm32")] + pub fn preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &str) { + use fmterr::fmt_err; + let url = url.to_string(); + + crate::spawn_local_scoped(cx, async move { + if let Err(err) = self.try_preload(&url).await { + panic!("{}", fmt_err(&err)); + } + }); + } + /// Preloads the given URL from the server and caches it for the current + /// route, preventing future network requests to fetch that page. On a + /// route transition, this will be removed. + /// + /// WARNING: the route preloading system is under heavy construction at + /// present! + /// + /// This function automatically defers the asynchronous preloading + /// work to a browser future for convenience. If you would like to + /// access the underlying future, use `.try_route_preload()` instead. + /// + /// # Panics + /// This function will panic if any errors occur in preloading, such as + /// the route being not found, or not localized. If the path you're + /// preloading is not hardcoded, use `.try_route_preload()` instead. + // Conveniently, we can use the lifetime mechanics of knowing that the render context + // is registered on the given scope to ensure that the future works out + #[cfg(target_arch = "wasm32")] + pub fn route_preload<'a, 'b: 'a>(&'b self, cx: Scope<'a>, url: &str) { + use fmterr::fmt_err; + let url = url.to_string(); + + crate::spawn_local_scoped(cx, async move { + if let Err(err) = self.try_route_preload(&url).await { + panic!("{}", fmt_err(&err)); + } + }); + } + /// A version of `.preload()` that returns a future that can resolve to an + /// error. If the path you're preloading is not hardcoded, you should + /// use this. + #[cfg(target_arch = "wasm32")] + pub async fn try_preload(&self, url: &str) -> Result<(), ClientError> { + self._preload(url, false).await + } + /// A version of `.route_preload()` that returns a future that can resolve + /// to an error. If the path you're preloading is not hardcoded, you + /// should use this. + #[cfg(target_arch = "wasm32")] + pub async fn try_route_preload(&self, url: &str) -> Result<(), ClientError> { + self._preload(url, true).await + } + /// Preloads the given URL from the server and caches it, preventing + /// future network requests to fetch that page. + #[cfg(target_arch = "wasm32")] + pub async fn _preload(&self, path: &str, is_route_preload: bool) -> Result<(), ClientError> { + use crate::router::{match_route, RouteVerdict}; + + let path_segments = path + .split('/') + .filter(|s| !s.is_empty()) + .collect::>(); // This parsing is identical to the Sycamore router's + // Get a route verdict on this so we know where we're going (this doesn't modify + // the router state) + let verdict = match_route( + &path_segments, + &self.render_cfg, + &self.templates, + &self.locales, + ); + // Make sure we've got a valid verdict (otherwise the user should be told there + // was an error) + let route_info = match verdict { + RouteVerdict::Found(info) => info, + RouteVerdict::NotFound => return Err(ClientError::PreloadNotFound), + RouteVerdict::LocaleDetection(dest) => return Err(ClientError::PreloadLocaleDetection), + }; + + // We just needed to acquire the arguments to this function + self.page_state_store + .preload( + path, + &route_info.locale, + &route_info.template.get_path(), + route_info.was_incremental_match, + is_route_preload, + ) + .await + } /// Commands Perseus to 'thaw' the app from the given frozen state. You'll /// also need to provide preferences for thawing, which allow you to control /// how different pages should prioritize frozen state over existing (or @@ -142,6 +297,12 @@ impl RenderCtx { /// An internal getter for the frozen state for the given page. When this is /// called, it will also add any frozen state it finds to the page state /// store, overriding what was already there. + /// + /// **Warning:** if the page has already been registered in the page state + /// store as not being able to receive state, this will silently fail. + /// If this occurs, something has gone horribly wrong, and panics will + /// almost certainly follow. (Basically, this should *never* happen. If + /// you're not using the macros, you may need to be careful of this.) fn get_frozen_page_state_and_register(&self, url: &str) -> Option<::Rx> where R: Clone + AnyFreeze + MakeUnrx, @@ -171,8 +332,11 @@ impl RenderCtx { // Then we convince the compiler that that actually is `R` with the // ludicrous trait bound at the beginning of this function let rx = unrx.make_rx(); - // And we do want to add this to the page state store - self.page_state_store.add(url, rx.clone()); + // And we do want to add this to the page state store (if this returns + // false, then this page was never supposed to receive state) + if !self.page_state_store.add_state(url, rx.clone()) { + return None; + } // Now we should remove this from the frozen state so we don't fall back to // it again drop(frozen_app_full); @@ -184,7 +348,9 @@ impl RenderCtx { Some(rx) } // If there's nothing in the frozen state, we'll fall back to the active state - None => self.page_state_store.get::<::Rx>(url), + None => self + .page_state_store + .get_state::<::Rx>(url), } } else { None @@ -202,7 +368,8 @@ impl RenderCtx { // unreactive version of `R` has the same properties as `R` itself <::Unrx as MakeRx>::Rx: Clone + AnyFreeze + MakeUnrx, { - self.page_state_store.get::<::Rx>(url) + self.page_state_store + .get_state::<::Rx>(url) } /// Gets either the active state or the frozen state for the given page. If /// `.thaw()` has been called, thaw preferences will be registered, which @@ -346,6 +513,13 @@ impl RenderCtx { } /// Registers a serialized and unreactive state string to the page state /// store, returning a fully reactive version. + /// + /// **Warning:** if the page has already been registered in the page state + /// store as not being able to receive state, this will silently fail + /// (i.e. the state will be returned, but it won't be registered). If this + /// occurs, something has gone horribly wrong, and panics will almost + /// certainly follow. (Basically, this should *never* happen. If you're + /// not using the macros, you may need to be careful of this.) pub fn register_page_state_str( &self, url: &str, @@ -362,7 +536,8 @@ impl RenderCtx { let unrx = serde_json::from_str::(state_str) .map_err(|err| ClientError::StateInvalid { source: err })?; let rx = unrx.make_rx(); - self.page_state_store.add(url, rx.clone()); + // Potential silent failure (see above) + let _ = self.page_state_store.add_state(url, rx.clone()); Ok(rx) } @@ -388,6 +563,12 @@ impl RenderCtx { Ok(rx) } + /// Registers a page as definitely taking no state, which allows it to be + /// cached fully, preventing unnecessary network requests. Any future + /// attempt to set state will lead to silent failures and/or panics. + pub fn register_page_no_state(&self, url: &str) { + self.page_state_store.set_state_never(url); + } } /// Gets the `RenderCtx` efficiently.