Skip to content

Commit

Permalink
feat: added router state (#115)
Browse files Browse the repository at this point in the history
* feat: added router state

Still need docs and a real-world example.

* fix: fixed intrusive plugin linter warnings

These occur because of code removal for Wasm, but they can confuse users
and make them think there's something wrong with Perseus, so they're now silenced.

* docs(book): added docs on router state

* fix: fixed cloning issue when hydration not used
  • Loading branch information
arctic-hen7 authored Jan 16, 2022
1 parent 77323d7 commit 9ee6904
Show file tree
Hide file tree
Showing 13 changed files with 158 additions and 19 deletions.
1 change: 1 addition & 0 deletions docs/next/en-US/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- [Templates and Routing](/docs/templates/intro)
- [Modifying the `<head>`](/docs/templates/metadata-modification)
- [Modifying HTTP Headers](/docs/templates/setting-headers)
- [Listening to the Router](/docs/templates/router-state)
- [Error Pages](/docs/error-pages)
- [Static Content](/docs/static-content)
- [Internationalization](/docs/i18n/intro)
Expand Down
19 changes: 19 additions & 0 deletions docs/next/en-US/templates/router-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Listening to the Router

Given that Perseus loads new pages without reloading the browser tab, users will have no way to know that their clicking on a link actually did anything, which can be extremely annoying for your users, and may even dissaude them from using your site! Usually, this is circumvented by how quickly Perseus can load a new page, but, if a user happens to be on a particularly slow connection, it could take several seconds.

To avoid this, many modern frameworks support a loading bar at the top of the page to show that something is actully happening. Some sites prefer a more intrusive full page overlay with a loading indicator. No matter what approach you choose, Perseus gets out of your way and lets you build it, by using *router state*. This is a Sycamore `ReadSignal` that you can get access to in your templates and then use to listen for events on the router.

## Usage

This example (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/router_state.rs)) shows using router state to create a simple indicator of the router's current state, though this could easily be extended into a progress bar, loading indicator, or the like.

```rust
{{#include ../../../../examples/showcase/src/templates/router_state.rs}}
```

The first step in using router state is accessing it, which can be done through Sycamore's [context API](https://sycamore-rs.netlify.app/docs/advanced/contexts). Specifically, we access the context of type `perseus::templates::RenderCtx` (which also includes other information), which has a field `router_state`, which contains an instance of `perseus::templates::RouterState`. Then, we can use the `.get_load_state()` method on that to get a `ReadSignal<perseus::templates::RouterLoadState>` (Sycamore-speak for a read-only piece of state). Next, we use Sycamore's `create_memo` to create some derived state (so it will update whenever the router's loading state does) that just turns the router's state into a string to render for the user.

As you can see, there are three mutually exclusive states the router can be in: `Loaded`, `Loading`, and `Server`. The first two of these have an attached `String` that indicates either the name of the template that has been loaded (in the first case) or the name of the template that is about to be loaded (in the second case). In the third state, you shouldn't do anything, because no router actually exists, as the page is being rendered on the server. Note that anything rendered in the `Server` state will be visible for a brief moment in the browser before the page is made interactive, which can cause ugly UI flashes.

As noted in the comments in this code, if you were to load this page and click the link to the `/about` page (which has the template name `about`), you would momentarily see `Loading about.` before the page loaded. During this time (i.e. when the router is in the `Loading` state), you may want to render some kind of progress bar or overlay to indicate to the user that a new page is being loaded.
17 changes: 14 additions & 3 deletions examples/basic/.perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ use perseus::{
shell::{app_shell, get_initial_state, get_render_cfg, InitialState},
},
plugins::PluginAction,
templates::TemplateNodeType,
templates::{RouterState, TemplateNodeType},
DomNode,
};
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::context::{ContextProvider, ContextProviderProps};
use sycamore::prelude::{cloned, create_effect, view, NodeRef, ReadSignal};
use sycamore_router::{HistoryIntegration, Router, RouterProps};
use wasm_bindgen::{prelude::wasm_bindgen, JsValue};
Expand Down Expand Up @@ -62,6 +63,10 @@ pub fn run() -> Result<(), JsValue> {
// Get the error pages in an `Rc` so we aren't creating hundreds of them
let error_pages = Rc::new(get_error_pages(&plugins));

// Create the router state we'll need
// TODO
let router_state = RouterState::default();

// Create the router we'll use for this app, based on the user's app definition
create_app_route! {
name => AppRoute,
Expand All @@ -84,7 +89,7 @@ pub fn run() -> Result<(), JsValue> {
// Sycamore's reactivity is broken by a future, so we need to explicitly add the route to the reactive dependencies here
// We do need the future though (otherwise `container_rx` doesn't link to anything until it's too late)
let _ = route.get();
wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, translations_manager, error_pages, initial_container) => async move {
wasm_bindgen_futures::spawn_local(cloned!((locales, route, container_rx, router_state, translations_manager, error_pages, initial_container) => async move {
let container_rx_elem = container_rx.get::<DomNode>().unchecked_into::<web_sys::Element>();
checkpoint("router_entry");
match &route.get().as_ref().0 {
Expand All @@ -99,6 +104,7 @@ pub fn run() -> Result<(), JsValue> {
path.clone(),
(template.clone(), *was_incremental_match),
locale.clone(),
router_state.clone(),
// We give the app shell a translations manager and let it get the `Rc<Translator>` itself (because it can do async safely)
Rc::clone(&translations_manager),
Rc::clone(&error_pages),
Expand Down Expand Up @@ -141,7 +147,12 @@ pub fn run() -> Result<(), JsValue> {
// However, the server has already rendered initial load content elsewhere, so we move that into here as well in the app shell
// The main reason for this is that the router only intercepts click events from its children
view! {
div(id="__perseus_content_rx", class="__perseus_content", ref=container_rx) {}
ContextProvider(ContextProviderProps {
value: "test".to_string(),
children: || view! {
div(id="__perseus_content_rx", class="__perseus_content", ref=container_rx) {}
}
})
}
}))
}
Expand Down
3 changes: 2 additions & 1 deletion examples/showcase/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ define_app! {
crate::templates::ip::get_template::<G>(),
crate::templates::time_root::get_template::<G>(),
crate::templates::time::get_template::<G>(),
crate::templates::amalgamation::get_template::<G>()
crate::templates::amalgamation::get_template::<G>(),
crate::templates::router_state::get_template::<G>()
],
error_pages: crate::error_pages::get_error_pages(),
locales: {
Expand Down
1 change: 1 addition & 0 deletions examples/showcase/src/templates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ pub mod index;
pub mod ip;
pub mod new_post;
pub mod post;
pub mod router_state;
pub mod time;
pub mod time_root;
28 changes: 28 additions & 0 deletions examples/showcase/src/templates/router_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use perseus::{templates::RouterLoadState, Html, Template};
use sycamore::prelude::{cloned, component, create_memo, view, View};

#[perseus::template(RouterStatePage)]
#[component(RouterStatePage<G>)]
pub fn router_state_page() -> View<G> {
let load_state = sycamore::context::use_context::<perseus::templates::RenderCtx>()
.router
.get_load_state();
let load_state_str = create_memo(
cloned!(load_state => move || match (*load_state.get()).clone() {
RouterLoadState::Loaded(name) => format!("Loaded {}.", name),
RouterLoadState::Loading(new) => format!("Loading {}.", new),
RouterLoadState::Server => "We're on the server.".to_string()
}),
);

view! {
a(href = "about", id = "about-link") { "About!" }


p { (load_state_str.get()) }
}
}

pub fn get_template<G: Html>() -> Template<G> {
Template::new("router_state").template(router_state_page)
}
20 changes: 16 additions & 4 deletions packages/perseus/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use crate::errors::*;
use crate::locales::Locales;
use crate::router::RouterState;
use crate::templates::TemplateMap;
use crate::translations_manager::TranslationsManager;
use crate::translator::Translator;
Expand Down Expand Up @@ -117,7 +118,12 @@ async fn gen_state_for_path(
.await?;
// Prerender the template using that state
let prerendered = sycamore::render_to_string(|| {
template.render_for_template(Some(initial_state.clone()), translator, true)
template.render_for_template(
Some(initial_state.clone()),
translator,
true,
RouterState::default(),
)
});
// Write that prerendered HTML to a static file
mutable_store
Expand Down Expand Up @@ -146,7 +152,12 @@ async fn gen_state_for_path(
.await?;
// Prerender the template using that state
let prerendered = sycamore::render_to_string(|| {
template.render_for_template(Some(initial_state.clone()), translator, true)
template.render_for_template(
Some(initial_state.clone()),
translator,
true,
RouterState::default(),
)
});
// Write that prerendered HTML to a static file
immutable_store
Expand Down Expand Up @@ -184,8 +195,9 @@ async fn gen_state_for_path(
// If the template is very basic, prerender without any state
// It's safe to add a property to the render options here because `.is_basic()` will only return true if path generation is not being used (or anything else)
if template.is_basic() {
let prerendered =
sycamore::render_to_string(|| template.render_for_template(None, translator, true));
let prerendered = sycamore::render_to_string(|| {
template.render_for_template(None, translator, true, RouterState::default())
});
let head_str = template.render_head_str(None, translator);
// Write that prerendered HTML to a static file
immutable_store
Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub use crate::template::{HeadFn, RenderFnResult, RenderFnResultWithCause, State
/// Utilities for developing templates, particularly including return types for various rendering strategies.
pub mod templates {
pub use crate::errors::{ErrorCause, GenericErrorWithCause};
pub use crate::router::{RouterLoadState, RouterState};
pub use crate::template::*;
// The engine needs to know whether or not to use hydration, this is how we pass those feature settings through
#[cfg(not(feature = "hydrate"))]
Expand Down
8 changes: 5 additions & 3 deletions packages/perseus/src/plugins/plugins_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ impl<G: Html> Plugins<G> {
/// Registers a new plugin, consuming `self`. For control actions, this will check if a plugin has already registered on an action,
/// and throw an error if one has, noting the conflict explicitly in the error message. This can only register plugins that run
/// exclusively on the server-side (including tinker-time and the build process).
// We allow unusued variables and the like for linting because otherwise any errors in Wasm compilation will show these up, which is annoying
pub fn plugin<D: Any + Send>(
mut self,
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))] mut self,
// This is a function so that it never gets called if we're compiling for Wasm, which means Rust eliminates it as dead code!
plugin: impl Fn() -> Plugin<G, D> + Send,
plugin_data: D,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] plugin: impl Fn() -> Plugin<G, D>
+ Send,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] plugin_data: D,
) -> Self {
// If we're compiling for Wasm, plugins that don't run on the client side shouldn't be added (they'll then be eliminated as dead code)
#[cfg(not(target_arch = "wasm32"))]
Expand Down
38 changes: 38 additions & 0 deletions packages/perseus/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use crate::Html;
use crate::Template;
use std::collections::HashMap;
use std::rc::Rc;
use sycamore::prelude::ReadSignal;
use sycamore::prelude::Signal;

/// The backend for `get_template_for_path` to avoid code duplication for the `Arc` and `Rc` versions.
macro_rules! get_template_for_path {
Expand Down Expand Up @@ -290,3 +292,39 @@ macro_rules! create_app_route {
}
};
}

/// The state for the router.
#[derive(Clone)]
pub struct RouterState {
/// The router's current load state.
load_state: Signal<RouterLoadState>,
}
impl Default for RouterState {
/// Creates a default instance of the router state intended for server-side usage.
fn default() -> Self {
Self {
load_state: Signal::new(RouterLoadState::Server),
}
}
}
impl RouterState {
/// Gets the load state of the router.
pub fn get_load_state(&self) -> ReadSignal<RouterLoadState> {
self.load_state.handle()
}
/// Sets the load state of the router.
pub fn set_load_state(&self, new: RouterLoadState) {
self.load_state.set(new);
}
}

/// The current load state of the router. You can use this to be warned of when a new page is about to be loaded (and display a loading bar or the like, perhaps).
#[derive(Clone)]
pub enum RouterLoadState {
/// The page has been loaded. The name of the template is attached.
Loaded(String),
/// A new page is being loaded, and will soon replace whatever is currently loaded. The name of the new template is attached.
Loading(String),
/// We're on the server, and there is no router. Whatever you render based on this state will appear when the user first loads the page, before it's made interactive.
Server,
}
12 changes: 9 additions & 3 deletions packages/perseus/src/server/render.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::decode_time_str::decode_time_str;
use crate::errors::*;
use crate::page_data::PageData;
use crate::router::RouterState;
use crate::stores::{ImmutableStore, MutableStore};
use crate::template::{States, Template, TemplateMap};
use crate::translations_manager::TranslationsManager;
Expand Down Expand Up @@ -75,7 +76,7 @@ async fn render_request_state(
);
// Use that to render the static HTML
let html = sycamore::render_to_string(|| {
template.render_for_template(state.clone(), translator, true)
template.render_for_template(state.clone(), translator, true, RouterState::default())
});
let head = template.render_head_str(state.clone(), translator);

Expand Down Expand Up @@ -160,7 +161,7 @@ async fn revalidate(
.await?,
);
let html = sycamore::render_to_string(|| {
template.render_for_template(state.clone(), translator, true)
template.render_for_template(state.clone(), translator, true, RouterState::default())
});
let head = template.render_head_str(state.clone(), translator);
// Handle revalidation, we need to parse any given time strings into datetimes
Expand Down Expand Up @@ -274,7 +275,12 @@ pub async fn get_page_for_template(
.await?,
);
let html_val = sycamore::render_to_string(|| {
template.render_for_template(state.clone(), &translator, true)
template.render_for_template(
state.clone(),
&translator,
true,
RouterState::default(),
)
});
let head_val = template.render_head_str(state.clone(), &translator);
// Handle revalidation, we need to parse any given time strings into datetimes
Expand Down
Loading

0 comments on commit 9ee6904

Please sign in to comment.