From 6e32c8f0d78e28495ac48224e56176a9d91a683f Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Thu, 27 Jan 2022 11:54:31 +1100 Subject: [PATCH] feat: added same-page reloading This finalizes state freezing/thawing fully! Closes #120. --- packages/perseus/src/router/app_route.rs | 3 +- packages/perseus/src/router/route_verdict.rs | 4 +- .../perseus/src/router/router_component.rs | 206 ++++++++++++------ packages/perseus/src/router/router_state.rs | 29 +++ packages/perseus/src/shell.rs | 9 +- packages/perseus/src/template/render_ctx.rs | 19 +- 6 files changed, 194 insertions(+), 76 deletions(-) diff --git a/packages/perseus/src/router/app_route.rs b/packages/perseus/src/router/app_route.rs index 83932ce8b6..30002610f5 100644 --- a/packages/perseus/src/router/app_route.rs +++ b/packages/perseus/src/router/app_route.rs @@ -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($crate::internal::router::RouteVerdict); impl $crate::internal::router::PerseusRoute for $name { fn get_verdict(&self) -> &$crate::internal::router::RouteVerdict { @@ -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: Route { +pub trait PerseusRoute: Route + Clone { /// Gets the route verdict for the current route. fn get_verdict(&self) -> &RouteVerdict; } diff --git a/packages/perseus/src/router/route_verdict.rs b/packages/perseus/src/router/route_verdict.rs index e6de624f95..e9dc258a46 100644 --- a/packages/perseus/src/router/route_verdict.rs +++ b/packages/perseus/src/router/route_verdict.rs @@ -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 { /// The actual path of the route. pub path: String, @@ -20,7 +20,7 @@ pub struct RouteInfo { /// 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 { /// The given route was found, and route information is attached. Found(RouteInfo), diff --git a/packages/perseus/src/router/router_component.rs b/packages/perseus/src/router/router_component.rs index c3da7a5bd6..8fca215033 100644 --- a/packages/perseus/src/router/router_component.rs +++ b/packages/perseus/src/router/router_component.rs @@ -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; @@ -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 { + locales: Rc, + container_rx: NodeRef, + router_state: RouterState, + pss: PageStateStore, + global_state: GlobalState, + frozen_app: Rc>>, + translations_manager: Rc>, + error_pages: Rc>, + initial_container: Option, +} + +/// 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( + verdict: RouteVerdict, + OnRouteChangeProps { + locales, + container_rx, + router_state, + pss, + global_state, + frozen_app, + translations_manager, + error_pages, + initial_container, + }: OnRouteChangeProps, +) { + wasm_bindgen_futures::spawn_local(async move { + let container_rx_elem = container_rx + .get::() + .unchecked_into::(); + 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 { @@ -133,73 +233,43 @@ pub fn perseus_router + '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| { - 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::().unchecked_into::(); - 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| { + // 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 @@ -209,6 +279,6 @@ pub fn perseus_router + 'static>( p(id = "__perseus_route_announcer", aria_live = "assertive", role = "alert", style = ROUTE_ANNOUNCER_STYLES) { (route_announcement.get()) } } } - })) + }))) } } diff --git a/packages/perseus/src/router/router_state.rs b/packages/perseus/src/router/router_state.rs index db163989c2..4c6008a935 100644 --- a/packages/perseus/src/router/router_state.rs +++ b/packages/perseus/src/router/router_state.rs @@ -1,3 +1,7 @@ +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. @@ -5,12 +9,20 @@ use sycamore::prelude::{ReadSignal, Signal}; pub struct RouterState { /// The router's current load state. load_state: Signal, + /// 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>>>, + /// 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, } 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), } } } @@ -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> { + (*self.last_verdict.borrow()).clone() + } + /// Sets the last verdict. + pub fn set_last_verdict(&mut self, new: RouteVerdict) { + 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). diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 10e26dc8c7..18f5e873a7 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -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}; @@ -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>>, + /// 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, } /// 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 @@ -268,7 +271,7 @@ pub async fn app_shell( template, was_incremental_match, locale, - router_state, + mut router_state, page_state_store, translations_manager, error_pages, @@ -276,6 +279,7 @@ pub async fn app_shell( container_rx_elem, global_state: curr_global_state, frozen_app, + route_verdict, }: ShellProps, ) { checkpoint("app_shell_entry"); @@ -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(); diff --git a/packages/perseus/src/template/render_ctx.rs b/packages/perseus/src/template/render_ctx.rs index 9841afd084..ab62d8de28 100644 --- a/packages/perseus/src/template/render_ctx.rs +++ b/packages/perseus/src/template/render_ctx.rs @@ -68,9 +68,22 @@ impl RenderCtx { *frozen_app = Some((new_frozen_app, thaw_prefs)); // I'm not absolutely certain about destructor behavior with navigation or how that could change with the new primitives, so better to be safe than sorry drop(frozen_app); - // Navigate to the frozen route - // TODO If we're on the same page, reload the page - navigate(&route); + + // Check if we're on the same page now as we were at freeze-time + let curr_route = match &*self.router.get_load_state().get_untracked() { + RouterLoadState::Loaded { path, .. } => path.to_string(), + RouterLoadState::Loading { path, .. } => path.to_string(), + // The user is trying to thaw on the server, which is an absolutely horrific idea (we should be generating state, and loops could happen) + RouterLoadState::Server => panic!("attempted to thaw frozen state on server-side (you can only do this in the browser)"), + }; + if curr_route == route { + // We'll need to imperatively instruct the router to reload the current page (Sycamore can't do this yet) + // We know the last verdict will be available because the only way we can be here is if we have a page + self.router.reload(); + } else { + // We aren't, navigate to the old route as usual + navigate(&route); + } Ok(()) }