Skip to content

Commit

Permalink
feat: added page state store caching, preloading, and memory manageme…
Browse files Browse the repository at this point in the history
…nt (#204)

* feat: added eviction to the pss

This should fix any memory blowouts (not leaks, just the storage of
every single page's state can get a bit heavy), which occasionally
occurred in development.

* chore: clarified a comment

* feat: created facility to support storage of document metadata in pss

* feat: prevented network requests for cached head/state

After a page is fetched as a subsequent load, it won't be fetched again
until the PSS fills up! This doesn't work with initial loads, since the
head is pre-interpolated (and I don't want to increase the bundle size
by doubling it up in a variable; reading from the HTML is unreliable
since JS will likely have already modified it).

* feat: added preloading infrastructure

This has involved making `RenderCtx` hold much more data in the browser,
like the error pages and render context, though this should make routing
much lighter.

* fix: fixed longstanding clones in app route system

Apparently, it's perfectly valid to pass the Sycamore scope through to
the Sycamore router, which means we an access everything we need from
the render context! (I reckon there'll be a performance improvement to
moving the render context into a dedicated system though, beyond
Sycamore's context.)

* refactor: removed unnecessary if clause in fetching example

I think this led to some confusion the other day, so it's clarified now.
Just that we don't need `G::IS_BROWSER` if we're target-gating as well.

* feat: created user-facing preload system

This includes a new `core/preload` example.

* chore: applied #212 fix to new `preload` example

* feat: added initially loaded page caching

This is achieved through an extra `<meta>` delimiter that denotes the
end of the `<head>`, which should be pretty reliable at getting what the
user intended.

* feat: added feature to control initial page caching

Advanced `<head>` manipulations *could* in rare cases lead to bugs, so
the user can turn this off if necessary, and it's documented in the FAQ section.
  • Loading branch information
arctic-hen7 authored Oct 29, 2022
1 parent 39501dc commit 0c4fa6b
Show file tree
Hide file tree
Showing 28 changed files with 1,065 additions and 340 deletions.
4 changes: 4 additions & 0 deletions docs/next/en-US/reference/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>`...

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 `<head>` 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 `<head>` works. Typically, a delimiter `<meta itemprop="__perseus_head_end">` is added to the end of the `<head>`, 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 `<head>` 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.
2 changes: 1 addition & 1 deletion examples/.base/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions examples/core/preload/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
target_engine/
target_wasm/
24 changes: 24 additions & 0 deletions examples/core/preload/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
3 changes: 3 additions & 0 deletions examples/core/preload/README.md
Original file line number Diff line number Diff line change
@@ -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!
32 changes: 32 additions & 0 deletions examples/core/preload/src/error_pages.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use perseus::{ErrorPages, Html};
use sycamore::view;

pub fn get_error_pages<G: Html>() -> ErrorPages<G> {
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
}
12 changes: 12 additions & 0 deletions examples/core/preload/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
mod error_pages;
mod templates;

use perseus::{Html, PerseusApp};

#[perseus::main(perseus_warp::dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new()
.template(crate::templates::index::get_template)
.template(crate::templates::about::get_template)
.error_pages(crate::error_pages::get_error_pages)
}
16 changes: 16 additions & 0 deletions examples/core/preload/src/templates/about.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use perseus::{Html, Template};
use sycamore::prelude::{view, Scope};
use sycamore::view::View;

#[perseus::template_rx]
pub fn about_page<G: Html>(cx: Scope) -> View<G> {
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<G: Html>() -> Template<G> {
Template::new("about").template(about_page)
}
34 changes: 34 additions & 0 deletions examples/core/preload/src/templates/index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use perseus::Template;
use sycamore::prelude::{view, Html, Scope, SsrNode, View};

#[perseus::template_rx]
pub fn index_page<G: Html>(cx: Scope) -> View<G> {
// 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<SsrNode> {
view! { cx,
title { "Index Page" }
}
}

pub fn get_template<G: Html>() -> Template<G> {
Template::new("index").template(index_page).head(head)
}
2 changes: 2 additions & 0 deletions examples/core/preload/src/templates/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod about;
pub mod index;
2 changes: 1 addition & 1 deletion examples/demos/fetching/src/templates/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/perseus-macro/src/template_rx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
},
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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)
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 `<head>` on that page. If you perform any advanced manipulation of the `<head>` 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 = []
Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub fn run_client<M: MutableStore, T: TranslationsManager>(
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
Expand Down
7 changes: 7 additions & 0 deletions packages/perseus/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -177,6 +181,9 @@ pub enum FetchError {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
// 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.
Expand Down
34 changes: 33 additions & 1 deletion packages/perseus/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ static DFLT_INDEX_VIEW: &str = r#"
<div id="root"></div>
</body>
</html>"#;
/// 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<G: Html>(Vec<Box<dyn Fn() -> Template<G>>>);
Expand Down Expand Up @@ -110,6 +117,8 @@ pub struct PerseusAppBase<G: Html, M: MutableStore, T: TranslationsManager> {
template_getters: TemplateGetters<G>,
/// The app's error pages.
error_pages: ErrorPagesGetter<G>,
/// 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...)
Expand Down Expand Up @@ -272,6 +281,7 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
// 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
Expand Down Expand Up @@ -313,6 +323,7 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
// 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 {
Expand Down Expand Up @@ -535,7 +546,23 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {

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 `<div>` at which to insert Perseus.
pub fn get_root(&self) -> String {
self.plugins
Expand Down Expand Up @@ -738,6 +765,11 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {

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"))]
Expand Down
2 changes: 2 additions & 0 deletions packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down
Loading

2 comments on commit 0c4fa6b

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.10.

Benchmark suite Current: 0c4fa6b Previous: 6eb10a4 Ratio
Wasm Bundle Size 278986 Bytes 250090 Bytes 1.12

This comment was automatically generated by workflow using github-action-benchmark.

CC: @arctic-hen7

@arctic-hen7
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Willing to accept this performance regression given the substantial performance improvements to the number of network requests Perseus apps make.

Please sign in to comment.