Skip to content

Commit 0c4fa6b

Browse files
authored
feat: added page state store caching, preloading, and memory management (#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.
1 parent 39501dc commit 0c4fa6b

File tree

28 files changed

+1065
-340
lines changed

28 files changed

+1065
-340
lines changed

docs/next/en-US/reference/faq.md

+4
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ Sycamore v0.8.0 has been released in beta to solve these problems and many other
3838
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).
3939

4040
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.
41+
42+
## I'm getting really weird errors with a page's `<head>`...
43+
44+
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.

examples/.base/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ edition = "2021"
77

88
[dependencies]
99
perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] }
10-
sycamore = "=0.8.0-beta.7"
10+
sycamore = "^0.8.1"
1111
serde = { version = "1", features = ["derive"] }
1212
serde_json = "1"
1313

examples/core/preload/.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist/
2+
target_engine/
3+
target_wasm/

examples/core/preload/Cargo.toml

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "perseus-example-preload"
3+
version = "0.4.0-beta.10"
4+
edition = "2021"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]
9+
perseus = { path = "../../../packages/perseus", features = [ "hydrate" ] }
10+
sycamore = "^0.8.1"
11+
serde = { version = "1", features = ["derive"] }
12+
serde_json = "1"
13+
14+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
15+
fantoccini = "0.17"
16+
17+
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
18+
tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread" ] }
19+
## **WARNING!** Before running this example outside the Perseus repo, replace the below line with
20+
## the one commented out below it (changing the path dependency to the version you want to use)
21+
perseus-warp = { package = "perseus-integration", path = "../../../packages/perseus-integration", default-features = false }
22+
# perseus-warp = { path = "../../../packages/perseus-warp", features = [ "dlft-server" ] }
23+
24+
[target.'cfg(target_arch = "wasm32")'.dependencies]

examples/core/preload/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Preload Example
2+
3+
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use perseus::{ErrorPages, Html};
2+
use sycamore::view;
3+
4+
pub fn get_error_pages<G: Html>() -> ErrorPages<G> {
5+
let mut error_pages = ErrorPages::new(
6+
|cx, url, status, err, _| {
7+
view! { cx,
8+
p { (format!("An error with HTTP code {} occurred at '{}': '{}'.", status, url, err)) }
9+
}
10+
},
11+
|cx, _, _, _, _| {
12+
view! { cx,
13+
title { "Error" }
14+
}
15+
},
16+
);
17+
error_pages.add_page(
18+
404,
19+
|cx, _, _, _, _| {
20+
view! { cx,
21+
p { "Page not found." }
22+
}
23+
},
24+
|cx, _, _, _, _| {
25+
view! { cx,
26+
title { "Not Found" }
27+
}
28+
},
29+
);
30+
31+
error_pages
32+
}

examples/core/preload/src/main.rs

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
mod error_pages;
2+
mod templates;
3+
4+
use perseus::{Html, PerseusApp};
5+
6+
#[perseus::main(perseus_warp::dflt_server)]
7+
pub fn main<G: Html>() -> PerseusApp<G> {
8+
PerseusApp::new()
9+
.template(crate::templates::index::get_template)
10+
.template(crate::templates::about::get_template)
11+
.error_pages(crate::error_pages::get_error_pages)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use perseus::{Html, Template};
2+
use sycamore::prelude::{view, Scope};
3+
use sycamore::view::View;
4+
5+
#[perseus::template_rx]
6+
pub fn about_page<G: Html>(cx: Scope) -> View<G> {
7+
view! { cx,
8+
p { "Check out your browser's network DevTools, no new requests were needed to get to this page!" }
9+
10+
a(id = "index-link", href = "") { "Index" }
11+
}
12+
}
13+
14+
pub fn get_template<G: Html>() -> Template<G> {
15+
Template::new("about").template(about_page)
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
use perseus::Template;
2+
use sycamore::prelude::{view, Html, Scope, SsrNode, View};
3+
4+
#[perseus::template_rx]
5+
pub fn index_page<G: Html>(cx: Scope) -> View<G> {
6+
// We can't preload pages on the engine-side
7+
#[cfg(target_arch = "wasm32")]
8+
{
9+
// Get the render context first, which is the one-stop-shop for everything
10+
// internal to Perseus in the browser
11+
let render_ctx = perseus::get_render_ctx!(cx);
12+
// This spawns a future in the background, and will panic if the page you give
13+
// doesn't exist (to handle those errors and manage the future, use
14+
// `.try_preload` instead)
15+
render_ctx.preload(cx, "about");
16+
}
17+
18+
view! { cx,
19+
p { "Open up your browser's DevTools, go to the network tab, and then click the link below..." }
20+
21+
a(href = "about") { "About" }
22+
}
23+
}
24+
25+
#[perseus::head]
26+
pub fn head(cx: Scope) -> View<SsrNode> {
27+
view! { cx,
28+
title { "Index Page" }
29+
}
30+
}
31+
32+
pub fn get_template<G: Html>() -> Template<G> {
33+
Template::new("index").template(index_page).head(head)
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod about;
2+
pub mod index;

examples/demos/fetching/src/templates/index.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub fn index_page<'a, G: Html>(
2222
#[cfg(target_arch = "wasm32")]
2323
// Because we only have `reqwasm` on the client-side, we make sure this is only *compiled* in
2424
// the browser as well
25-
if G::IS_BROWSER && browser_ip.get().is_none() {
25+
if browser_ip.get().is_none() {
2626
// Spawn a `Future` on this thread to fetch the data (`spawn_local` is
2727
// re-exported from `wasm-bindgen-futures`) Don't worry, this doesn't
2828
// need to be sent to JavaScript for execution

packages/perseus-macro/src/template_rx.rs

+9
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ pub fn template_impl(input: TemplateFn) -> TokenStream {
202202
#block
203203
}
204204

205+
// Declare that this page will never take any state to enable full caching
206+
render_ctx.register_page_no_state(&props.path);
207+
205208
#component_name(cx)
206209
}
207210
},
@@ -241,6 +244,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream {
241244
let render_ctx = ::perseus::get_render_ctx!(cx);
242245
// 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!
243246
match render_ctx.get_active_or_frozen_page_state::<#rx_props_ty>(&props.path) {
247+
// 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
244248
::std::option::Option::Some(existing_state) => existing_state,
245249
// Again, frozen state has been dealt with already, so we'll fall back to generated state
246250
::std::option::Option::None => {
@@ -286,6 +290,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream {
286290
let render_ctx = ::perseus::get_render_ctx!(cx);
287291
// 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!
288292
match render_ctx.get_active_or_frozen_page_state::<#rx_props_ty>(&props.path) {
293+
// 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
289294
::std::option::Option::Some(existing_state) => existing_state,
290295
// Again, frozen state has been dealt with already, so we'll fall back to generated state
291296
::std::option::Option::None => {
@@ -316,6 +321,10 @@ pub fn template_impl(input: TemplateFn) -> TokenStream {
316321
#block
317322
}
318323

324+
// Declare that this page will never take any state to enable full caching
325+
let render_ctx = ::perseus::get_render_ctx!(cx);
326+
render_ctx.register_page_no_state(&props.path);
327+
319328
#component_name(cx)
320329
}
321330
}

packages/perseus/Cargo.toml

+5-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ wasm-bindgen-futures = "0.4"
4949
[features]
5050
# Live reloading will only take effect in development, and won't impact production
5151
# BUG This adds 1.9kB to the production bundle (that's without size optimizations though)
52-
default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine", "minify", "minify-css" ]
52+
default = [ "live-reload", "hsr", "client-helpers", "macros", "dflt-engine", "minify", "minify-css", "cache-initial-load" ]
5353
translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"]
5454
translator-lightweight = []
5555
# 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" ]
6363
minify = []
6464
minify-js = [ "minify" ]
6565
minify-css = [ "minify" ]
66+
# 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
67+
# (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
68+
# scratch, going somewhere else, and then going back to it breaks something, disable this.
69+
cache-initial-load = []
6670
# This feature enables Sycamore hydration by default (Sycamore hydration feature is always activated though)
6771
# This is not enabled by default due to some remaining bugs (also, default features in Perseus can't be disabled without altering `.perseus/`)
6872
hydrate = []

packages/perseus/src/client.rs

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pub fn run_client<M: MutableStore, T: TranslationsManager>(
5151
error_pages: app.get_error_pages(),
5252
templates: app.get_templates_map(),
5353
render_cfg: get_render_cfg().expect("render configuration invalid or not injected"),
54+
pss_max_size: app.get_pss_max_size(),
5455
};
5556

5657
// At this point, the user can already see something from the server-side

packages/perseus/src/errors.rs

+7
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ pub enum ClientError {
7676
#[source]
7777
source: serde_json::Error,
7878
},
79+
#[error("the given path for preloading leads to a locale detection page; you probably wanted to wrap the path in `link!(...)`")]
80+
PreloadLocaleDetection,
81+
#[error("the given path for preloading was not found")]
82+
PreloadNotFound,
7983
}
8084

8185
/// Errors that can occur in the build process or while the server is running.
@@ -177,6 +181,9 @@ pub enum FetchError {
177181
#[source]
178182
source: Box<dyn std::error::Error + Send + Sync>,
179183
},
184+
// This is not used by the `fetch` function, but it is used by the preloading system
185+
#[error("asset not found")]
186+
NotFound { url: String },
180187
}
181188

182189
/// Errors that can occur while building an app.

packages/perseus/src/init.rs

+33-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ static DFLT_INDEX_VIEW: &str = r#"
3939
<div id="root"></div>
4040
</body>
4141
</html>"#;
42+
/// The default number of pages the page state store will allow before evicting
43+
/// the oldest. Note: we don't allow an infinite number in development here
44+
/// because that does actually get quite problematic after a few hours of
45+
/// constant reloading and HSR (as in Firefox decides that opening the DevTools
46+
/// is no longer allowed).
47+
// TODO What's a sensible value here?
48+
static DFLT_PSS_MAX_SIZE: usize = 25;
4249

4350
// This is broken out for debug implementation ease
4451
struct TemplateGetters<G: Html>(Vec<Box<dyn Fn() -> Template<G>>>);
@@ -110,6 +117,8 @@ pub struct PerseusAppBase<G: Html, M: MutableStore, T: TranslationsManager> {
110117
template_getters: TemplateGetters<G>,
111118
/// The app's error pages.
112119
error_pages: ErrorPagesGetter<G>,
120+
/// The maximum size for the page state store.
121+
pss_max_size: usize,
113122
/// The global state creator for the app.
114123
// This is wrapped in an `Arc` so we can pass it around on the engine-side (which is solely for
115124
// Actix's benefit...)
@@ -272,6 +281,7 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
272281
// We do offer default error pages, but they'll panic if they're called for production
273282
// building
274283
error_pages: ErrorPagesGetter(Box::new(ErrorPages::default)),
284+
pss_max_size: DFLT_PSS_MAX_SIZE,
275285
#[cfg(not(target_arch = "wasm32"))]
276286
global_state_creator: Arc::new(GlobalStateCreator::default()),
277287
// By default, we'll disable i18n (as much as I may want more websites to support more
@@ -313,6 +323,7 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
313323
// We do offer default error pages, but they'll panic if they're called for production
314324
// building
315325
error_pages: ErrorPagesGetter(Box::new(ErrorPages::default)),
326+
pss_max_size: DFLT_PSS_MAX_SIZE,
316327
// By default, we'll disable i18n (as much as I may want more websites to support more
317328
// languages...)
318329
locales: Locales {
@@ -535,7 +546,23 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
535546

536547
self
537548
}
538-
// Setters
549+
/// Sets the maximum number of pages that can have their states stored in
550+
/// the page state store before the oldest will be evicted. If your app is
551+
/// taking up a substantial amount of memory in the browser because your
552+
/// page states are fairly large, making this smaller may help.
553+
///
554+
/// By default, this is set to 25. Higher values may lead to memory
555+
/// difficulties in both development and production, and the poor user
556+
/// experience of a browser that's substantially slowed down.
557+
///
558+
/// WARNING: any setting applied here will impact HSR in development! (E.g.
559+
/// setting this to 1 would mean your position would only be
560+
/// saved for the most recent page.)
561+
pub fn pss_max_size(mut self, val: usize) -> Self {
562+
self.pss_max_size = val;
563+
self
564+
}
565+
// Getters
539566
/// Gets the HTML ID of the `<div>` at which to insert Perseus.
540567
pub fn get_root(&self) -> String {
541568
self.plugins
@@ -738,6 +765,11 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
738765

739766
error_pages
740767
}
768+
/// Gets the maximum number of pages that can be stored in the page state
769+
/// store before the oldest are evicted.
770+
pub fn get_pss_max_size(&self) -> usize {
771+
self.pss_max_size
772+
}
741773
/// Gets the [`GlobalStateCreator`]. This can't be directly modified by
742774
/// plugins because of reactive type complexities.
743775
#[cfg(not(target_arch = "wasm32"))]

packages/perseus/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub use http;
7171
#[cfg(not(target_arch = "wasm32"))]
7272
pub use http::Request as HttpRequest;
7373
pub use sycamore_futures::spawn_local_scoped;
74+
#[cfg(target_arch = "wasm32")]
75+
pub use wasm_bindgen_futures::spawn_local;
7476
/// All HTTP requests use empty bodies for simplicity of passing them around.
7577
/// They'll never need payloads (value in path requested).
7678
#[cfg(not(target_arch = "wasm32"))]

0 commit comments

Comments
 (0)