Skip to content

Commit

Permalink
feat: added same-page reloading
Browse files Browse the repository at this point in the history
This finalizes state freezing/thawing fully!

Closes #120.
  • Loading branch information
arctic-hen7 committed Jan 27, 2022
1 parent b1c4746 commit 6e32c8f
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 76 deletions.
3 changes: 2 additions & 1 deletion packages/perseus/src/router/app_route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ macro_rules! create_app_route {
locales => $locales:expr
} => {
/// The route type for the app, with all routing logic inbuilt through the generation macro.
#[derive(::std::clone::Clone)]
struct $name<G: $crate::Html>($crate::internal::router::RouteVerdict<G>);
impl<G: $crate::Html> $crate::internal::router::PerseusRoute<G> for $name<G> {
fn get_verdict(&self) -> &$crate::internal::router::RouteVerdict<G> {
Expand All @@ -32,7 +33,7 @@ macro_rules! create_app_route {

/// A trait for the routes in Perseus apps. This should be used almost exclusively internally, and you should never need to touch
/// it unless you're building a custom engine.
pub trait PerseusRoute<G: Html>: Route {
pub trait PerseusRoute<G: Html>: Route + Clone {
/// Gets the route verdict for the current route.
fn get_verdict(&self) -> &RouteVerdict<G>;
}
4 changes: 2 additions & 2 deletions packages/perseus/src/router/route_verdict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::rc::Rc;

/// Information about a route, which, combined with error pages and a client-side translations manager, allows the initialization of
/// the app shell and the rendering of a page.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct RouteInfo<G: Html> {
/// The actual path of the route.
pub path: String,
Expand All @@ -20,7 +20,7 @@ pub struct RouteInfo<G: Html> {
/// The possible outcomes of matching a route. This is an alternative implementation of Sycamore's `Route` trait to enable greater
/// control and tighter integration of routing with templates. This can only be used if `Routes` has been defined in context (done
/// automatically by the CLI).
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum RouteVerdict<G: Html> {
/// The given route was found, and route information is attached.
Found(RouteInfo<G>),
Expand Down
206 changes: 138 additions & 68 deletions packages/perseus/src/router/router_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
},
state::{FrozenApp, GlobalState, PageStateStore, ThawPrefs},
templates::{RouterLoadState, RouterState, TemplateNodeType},
DomNode, ErrorPages,
DomNode, ErrorPages, Html,
};
use std::cell::RefCell;
use std::rc::Rc;
Expand All @@ -35,6 +35,106 @@ const ROUTE_ANNOUNCER_STYLES: &str = r#"
word-wrap: normal;
"#;

/// The properties that `on_route_change` takes.
#[derive(Debug, Clone)]
struct OnRouteChangeProps<G: Html> {
locales: Rc<Locales>,
container_rx: NodeRef<G>,
router_state: RouterState,
pss: PageStateStore,
global_state: GlobalState,
frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
translations_manager: Rc<RefCell<ClientTranslationsManager>>,
error_pages: Rc<ErrorPages<DomNode>>,
initial_container: Option<Element>,
}

/// The function that runs when a route change takes place. This can also be run at any time to force the current page to reload.
fn on_route_change<G: Html>(
verdict: RouteVerdict<TemplateNodeType>,
OnRouteChangeProps {
locales,
container_rx,
router_state,
pss,
global_state,
frozen_app,
translations_manager,
error_pages,
initial_container,
}: OnRouteChangeProps<G>,
) {
wasm_bindgen_futures::spawn_local(async move {
let container_rx_elem = container_rx
.get::<DomNode>()
.unchecked_into::<web_sys::Element>();
checkpoint("router_entry");
match &verdict {
// Perseus' custom routing system is tightly coupled to the template system, and returns exactly what we need for the app shell!
// If a non-404 error occurred, it will be handled in the app shell
RouteVerdict::Found(RouteInfo {
path,
template,
locale,
was_incremental_match,
}) => {
app_shell(
// TODO Make this not allocate so much
ShellProps {
path: path.clone(),
template: template.clone(),
was_incremental_match: *was_incremental_match,
locale: locale.clone(),
router_state,
translations_manager,
error_pages,
initial_container: initial_container.unwrap(),
container_rx_elem,
page_state_store: pss,
global_state,
frozen_app,
route_verdict: verdict,
},
)
.await
}
// If the user is using i18n, then they'll want to detect the locale on any paths missing a locale
// Those all go to the same system that redirects to the appropriate locale
// Note that `container` doesn't exist for this scenario
RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), &locales),
// To get a translator here, we'd have to go async and dangerously check the URL
// If this is an initial load, there'll already be an error message, so we should only proceed if the declaration is not `error`
// BUG If we have an error in a subsequent load, the error message appears below the current page...
RouteVerdict::NotFound => {
checkpoint("not_found");
if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state()
{
let initial_container = initial_container.unwrap();
// We need to move the server-rendered content from its current container to the reactive container (otherwise Sycamore can't work with it properly)
// If we're not hydrating, there's no point in moving anything over, we'll just fully re-render
#[cfg(feature = "hydrate")]
{
let initial_html = initial_container.inner_html();
container_rx_elem.set_inner_html(&initial_html);
}
initial_container.set_inner_html("");
// Make the initial container invisible
initial_container
.set_attribute("style", "display: none;")
.unwrap();
// Hydrate the error pages
// Right now, we don't provide translators to any error pages that have come from the server
error_pages.render_page(&url, status, &err, None, &container_rx_elem);
} else {
// This is an error from navigating within the app (probably the dev mistyped a link...), so we'll clear the page
container_rx_elem.set_inner_html("");
error_pages.render_page("", 404, "not found", None, &container_rx_elem);
}
}
};
});
}

/// The properties that the router takes.
#[derive(Debug)]
pub struct PerseusRouterProps {
Expand Down Expand Up @@ -133,73 +233,43 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
}),
);

// 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 on_route_change_props = OnRouteChangeProps {
locales,
container_rx: container_rx.clone(),
router_state: router_state.clone(),
pss,
global_state,
frozen_app,
translations_manager,
error_pages,
initial_container,
};

// Listen for changes to the reload commander and reload as appropriate
let reload_commander = router_state.reload_commander.clone();
create_effect(
cloned!(router_state, reload_commander, on_route_change_props => move || {
// This is just a flip-flop, but we need to add it to the effect's dependencies
let _ = reload_commander.get();
// Get the route verdict and re-run the function we use on route changes
let verdict = match router_state.get_last_verdict() {
Some(verdict) => verdict,
// If the first page hasn't loaded yet, terminate now
None => return
};
on_route_change(verdict, on_route_change_props.clone());
}),
);

view! {
Router(RouterProps::new(HistoryIntegration::new(), move |route: ReadSignal<AppRoute>| {
create_effect(cloned!((container_rx) => move || {
// 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, router_state, pss, global_state, frozen_app, 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().get_verdict() {
// Perseus' custom routing system is tightly coupled to the template system, and returns exactly what we need for the app shell!
// If a non-404 error occurred, it will be handled in the app shell
RouteVerdict::Found(RouteInfo {
path,
template,
locale,
was_incremental_match
}) => app_shell(
// TODO Make this not allocate so much...
ShellProps {
path: path.clone(),
template: template.clone(),
was_incremental_match: *was_incremental_match,
locale: locale.clone(),
router_state: router_state.clone(),
translations_manager: translations_manager.clone(),
error_pages: error_pages.clone(),
initial_container: initial_container.unwrap().clone(),
container_rx_elem: container_rx_elem.clone(),
page_state_store: pss.clone(),
global_state: global_state.clone(),
frozen_app
}
).await,
// If the user is using i18n, then they'll want to detect the locale on any paths missing a locale
// Those all go to the same system that redirects to the appropriate locale
// Note that `container` doesn't exist for this scenario
RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), &locales),
// To get a translator here, we'd have to go async and dangerously check the URL
// If this is an initial load, there'll already be an error message, so we should only proceed if the declaration is not `error`
// BUG If we have an error in a subsequent load, the error message appears below the current page...
RouteVerdict::NotFound => {
checkpoint("not_found");
if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state() {
let initial_container = initial_container.unwrap();
// We need to move the server-rendered content from its current container to the reactive container (otherwise Sycamore can't work with it properly)
// If we're not hydrating, there's no point in moving anything over, we'll just fully re-render
#[cfg(feature = "hydrate")]
{
let initial_html = initial_container.inner_html();
container_rx_elem.set_inner_html(&initial_html);
}
initial_container.set_inner_html("");
// Make the initial container invisible
initial_container.set_attribute("style", "display: none;").unwrap();
// Hydrate the error pages
// Right now, we don't provide translators to any error pages that have come from the server
error_pages.render_page(&url, status, &err, None, &container_rx_elem);
} else {
// This is an error from navigating within the app (probably the dev mistyped a link...), so we'll clear the page
container_rx_elem.set_inner_html("");
error_pages.render_page("", 404, "not found", None, &container_rx_elem);
}
},
};
}));
}));
Router(RouterProps::new(HistoryIntegration::new(), cloned!(on_route_change_props => move |route: ReadSignal<AppRoute>| {
// 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 verdict = route.get().get_verdict().clone();
on_route_change(verdict, on_route_change_props);

// This template is reactive, and will be updated as necessary
// 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
Expand All @@ -209,6 +279,6 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
p(id = "__perseus_route_announcer", aria_live = "assertive", role = "alert", style = ROUTE_ANNOUNCER_STYLES) { (route_announcement.get()) }
}
}
}))
})))
}
}
29 changes: 29 additions & 0 deletions packages/perseus/src/router/router_state.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
use super::RouteVerdict;
use crate::templates::TemplateNodeType;
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::{ReadSignal, Signal};

/// The state for the router.
#[derive(Clone, Debug)]
pub struct RouterState {
/// The router's current load state.
load_state: Signal<RouterLoadState>,
/// The last route verdict. We can come back to this if we need to reload the current page without losing context etc.
last_verdict: Rc<RefCell<Option<RouteVerdict<TemplateNodeType>>>>,
/// A flip-flop `Signal`. Whenever this is changed, the router will reload the current page in the SPA style. As a user,
/// you should rarely ever need to do this, but it's used internally in the thawing process.
pub(crate) reload_commander: Signal<bool>,
}
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),
last_verdict: Rc::new(RefCell::new(None)),
// It doesn't matter what we initialize this as, it's just for signalling
reload_commander: Signal::new(true),
}
}
}
Expand All @@ -23,6 +35,23 @@ impl RouterState {
pub fn set_load_state(&self, new: RouterLoadState) {
self.load_state.set(new);
}
/// Gets the last verdict.
pub fn get_last_verdict(&self) -> Option<RouteVerdict<TemplateNodeType>> {
(*self.last_verdict.borrow()).clone()
}
/// Sets the last verdict.
pub fn set_last_verdict(&mut self, new: RouteVerdict<TemplateNodeType>) {
let mut last_verdict = self.last_verdict.borrow_mut();
*last_verdict = Some(new);
}
/// Orders the router to reload the current page as if you'd called `navigate()` to it (but that would do nothing). This
/// enables reloading in an SPA style (but you should almost never need it).
///
/// Warning: if you're trying to rest your app, do NOT use this! Instead, reload the page fully through `web_sys`.
pub fn reload(&self) {
self.reload_commander
.set(!*self.reload_commander.get_untracked())
}
}

/// 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).
Expand Down
9 changes: 7 additions & 2 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::error_pages::ErrorPageData;
use crate::errors::*;
use crate::i18n::ClientTranslationsManager;
use crate::router::{RouterLoadState, RouterState};
use crate::router::{RouteVerdict, RouterLoadState, RouterState};
use crate::server::PageData;
use crate::state::PageStateStore;
use crate::state::{FrozenApp, GlobalState, ThawPrefs};
Expand Down Expand Up @@ -257,6 +257,9 @@ pub struct ShellProps {
pub global_state: GlobalState,
/// A previous frozen state to be gradully rehydrated. This should always be `None`, it only serves to provide continuity across templates.
pub frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
/// 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.
pub route_verdict: RouteVerdict<TemplateNodeType>,
}

/// Fetches the information for the given page and renders it. This should be provided the actual path of the page to render (not just the
Expand All @@ -268,14 +271,15 @@ pub async fn app_shell(
template,
was_incremental_match,
locale,
router_state,
mut router_state,
page_state_store,
translations_manager,
error_pages,
initial_container,
container_rx_elem,
global_state: curr_global_state,
frozen_app,
route_verdict,
}: ShellProps,
) {
checkpoint("app_shell_entry");
Expand All @@ -288,6 +292,7 @@ pub async fn app_shell(
template_name: template.get_path(),
path: path_with_locale.clone(),
});
router_state.set_last_verdict(route_verdict);
// Get the global state if possible (we'll want this in all cases except errors)
// If this is a subsequent load, the template macro will have already set up the global state, and it will ignore whatever we naively give it (so we'll give it `None`)
let global_state = get_global_state();
Expand Down
Loading

0 comments on commit 6e32c8f

Please sign in to comment.