Skip to content

Commit 6e32c8f

Browse files
committed
feat: added same-page reloading
This finalizes state freezing/thawing fully! Closes #120.
1 parent b1c4746 commit 6e32c8f

File tree

6 files changed

+194
-76
lines changed

6 files changed

+194
-76
lines changed

packages/perseus/src/router/app_route.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ macro_rules! create_app_route {
1515
locales => $locales:expr
1616
} => {
1717
/// The route type for the app, with all routing logic inbuilt through the generation macro.
18+
#[derive(::std::clone::Clone)]
1819
struct $name<G: $crate::Html>($crate::internal::router::RouteVerdict<G>);
1920
impl<G: $crate::Html> $crate::internal::router::PerseusRoute<G> for $name<G> {
2021
fn get_verdict(&self) -> &$crate::internal::router::RouteVerdict<G> {
@@ -32,7 +33,7 @@ macro_rules! create_app_route {
3233

3334
/// A trait for the routes in Perseus apps. This should be used almost exclusively internally, and you should never need to touch
3435
/// it unless you're building a custom engine.
35-
pub trait PerseusRoute<G: Html>: Route {
36+
pub trait PerseusRoute<G: Html>: Route + Clone {
3637
/// Gets the route verdict for the current route.
3738
fn get_verdict(&self) -> &RouteVerdict<G>;
3839
}

packages/perseus/src/router/route_verdict.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::rc::Rc;
44

55
/// Information about a route, which, combined with error pages and a client-side translations manager, allows the initialization of
66
/// the app shell and the rendering of a page.
7-
#[derive(Debug)]
7+
#[derive(Debug, Clone)]
88
pub struct RouteInfo<G: Html> {
99
/// The actual path of the route.
1010
pub path: String,
@@ -20,7 +20,7 @@ pub struct RouteInfo<G: Html> {
2020
/// The possible outcomes of matching a route. This is an alternative implementation of Sycamore's `Route` trait to enable greater
2121
/// control and tighter integration of routing with templates. This can only be used if `Routes` has been defined in context (done
2222
/// automatically by the CLI).
23-
#[derive(Debug)]
23+
#[derive(Debug, Clone)]
2424
pub enum RouteVerdict<G: Html> {
2525
/// The given route was found, and route information is attached.
2626
Found(RouteInfo<G>),

packages/perseus/src/router/router_component.rs

+138-68
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::{
99
},
1010
state::{FrozenApp, GlobalState, PageStateStore, ThawPrefs},
1111
templates::{RouterLoadState, RouterState, TemplateNodeType},
12-
DomNode, ErrorPages,
12+
DomNode, ErrorPages, Html,
1313
};
1414
use std::cell::RefCell;
1515
use std::rc::Rc;
@@ -35,6 +35,106 @@ const ROUTE_ANNOUNCER_STYLES: &str = r#"
3535
word-wrap: normal;
3636
"#;
3737

38+
/// The properties that `on_route_change` takes.
39+
#[derive(Debug, Clone)]
40+
struct OnRouteChangeProps<G: Html> {
41+
locales: Rc<Locales>,
42+
container_rx: NodeRef<G>,
43+
router_state: RouterState,
44+
pss: PageStateStore,
45+
global_state: GlobalState,
46+
frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
47+
translations_manager: Rc<RefCell<ClientTranslationsManager>>,
48+
error_pages: Rc<ErrorPages<DomNode>>,
49+
initial_container: Option<Element>,
50+
}
51+
52+
/// 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.
53+
fn on_route_change<G: Html>(
54+
verdict: RouteVerdict<TemplateNodeType>,
55+
OnRouteChangeProps {
56+
locales,
57+
container_rx,
58+
router_state,
59+
pss,
60+
global_state,
61+
frozen_app,
62+
translations_manager,
63+
error_pages,
64+
initial_container,
65+
}: OnRouteChangeProps<G>,
66+
) {
67+
wasm_bindgen_futures::spawn_local(async move {
68+
let container_rx_elem = container_rx
69+
.get::<DomNode>()
70+
.unchecked_into::<web_sys::Element>();
71+
checkpoint("router_entry");
72+
match &verdict {
73+
// Perseus' custom routing system is tightly coupled to the template system, and returns exactly what we need for the app shell!
74+
// If a non-404 error occurred, it will be handled in the app shell
75+
RouteVerdict::Found(RouteInfo {
76+
path,
77+
template,
78+
locale,
79+
was_incremental_match,
80+
}) => {
81+
app_shell(
82+
// TODO Make this not allocate so much
83+
ShellProps {
84+
path: path.clone(),
85+
template: template.clone(),
86+
was_incremental_match: *was_incremental_match,
87+
locale: locale.clone(),
88+
router_state,
89+
translations_manager,
90+
error_pages,
91+
initial_container: initial_container.unwrap(),
92+
container_rx_elem,
93+
page_state_store: pss,
94+
global_state,
95+
frozen_app,
96+
route_verdict: verdict,
97+
},
98+
)
99+
.await
100+
}
101+
// If the user is using i18n, then they'll want to detect the locale on any paths missing a locale
102+
// Those all go to the same system that redirects to the appropriate locale
103+
// Note that `container` doesn't exist for this scenario
104+
RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), &locales),
105+
// To get a translator here, we'd have to go async and dangerously check the URL
106+
// If this is an initial load, there'll already be an error message, so we should only proceed if the declaration is not `error`
107+
// BUG If we have an error in a subsequent load, the error message appears below the current page...
108+
RouteVerdict::NotFound => {
109+
checkpoint("not_found");
110+
if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state()
111+
{
112+
let initial_container = initial_container.unwrap();
113+
// We need to move the server-rendered content from its current container to the reactive container (otherwise Sycamore can't work with it properly)
114+
// If we're not hydrating, there's no point in moving anything over, we'll just fully re-render
115+
#[cfg(feature = "hydrate")]
116+
{
117+
let initial_html = initial_container.inner_html();
118+
container_rx_elem.set_inner_html(&initial_html);
119+
}
120+
initial_container.set_inner_html("");
121+
// Make the initial container invisible
122+
initial_container
123+
.set_attribute("style", "display: none;")
124+
.unwrap();
125+
// Hydrate the error pages
126+
// Right now, we don't provide translators to any error pages that have come from the server
127+
error_pages.render_page(&url, status, &err, None, &container_rx_elem);
128+
} else {
129+
// This is an error from navigating within the app (probably the dev mistyped a link...), so we'll clear the page
130+
container_rx_elem.set_inner_html("");
131+
error_pages.render_page("", 404, "not found", None, &container_rx_elem);
132+
}
133+
}
134+
};
135+
});
136+
}
137+
38138
/// The properties that the router takes.
39139
#[derive(Debug)]
40140
pub struct PerseusRouterProps {
@@ -133,73 +233,43 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
133233
}),
134234
);
135235

236+
// Set up the function we'll call on a route change
237+
// Set up the properties for the function we'll call in a route change
238+
let on_route_change_props = OnRouteChangeProps {
239+
locales,
240+
container_rx: container_rx.clone(),
241+
router_state: router_state.clone(),
242+
pss,
243+
global_state,
244+
frozen_app,
245+
translations_manager,
246+
error_pages,
247+
initial_container,
248+
};
249+
250+
// Listen for changes to the reload commander and reload as appropriate
251+
let reload_commander = router_state.reload_commander.clone();
252+
create_effect(
253+
cloned!(router_state, reload_commander, on_route_change_props => move || {
254+
// This is just a flip-flop, but we need to add it to the effect's dependencies
255+
let _ = reload_commander.get();
256+
// Get the route verdict and re-run the function we use on route changes
257+
let verdict = match router_state.get_last_verdict() {
258+
Some(verdict) => verdict,
259+
// If the first page hasn't loaded yet, terminate now
260+
None => return
261+
};
262+
on_route_change(verdict, on_route_change_props.clone());
263+
}),
264+
);
265+
136266
view! {
137-
Router(RouterProps::new(HistoryIntegration::new(), move |route: ReadSignal<AppRoute>| {
138-
create_effect(cloned!((container_rx) => move || {
139-
// Sycamore's reactivity is broken by a future, so we need to explicitly add the route to the reactive dependencies here
140-
// We do need the future though (otherwise `container_rx` doesn't link to anything until it's too late)
141-
let _ = route.get();
142-
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 {
143-
let container_rx_elem = container_rx.get::<DomNode>().unchecked_into::<web_sys::Element>();
144-
checkpoint("router_entry");
145-
match &route.get().as_ref().get_verdict() {
146-
// Perseus' custom routing system is tightly coupled to the template system, and returns exactly what we need for the app shell!
147-
// If a non-404 error occurred, it will be handled in the app shell
148-
RouteVerdict::Found(RouteInfo {
149-
path,
150-
template,
151-
locale,
152-
was_incremental_match
153-
}) => app_shell(
154-
// TODO Make this not allocate so much...
155-
ShellProps {
156-
path: path.clone(),
157-
template: template.clone(),
158-
was_incremental_match: *was_incremental_match,
159-
locale: locale.clone(),
160-
router_state: router_state.clone(),
161-
translations_manager: translations_manager.clone(),
162-
error_pages: error_pages.clone(),
163-
initial_container: initial_container.unwrap().clone(),
164-
container_rx_elem: container_rx_elem.clone(),
165-
page_state_store: pss.clone(),
166-
global_state: global_state.clone(),
167-
frozen_app
168-
}
169-
).await,
170-
// If the user is using i18n, then they'll want to detect the locale on any paths missing a locale
171-
// Those all go to the same system that redirects to the appropriate locale
172-
// Note that `container` doesn't exist for this scenario
173-
RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), &locales),
174-
// To get a translator here, we'd have to go async and dangerously check the URL
175-
// If this is an initial load, there'll already be an error message, so we should only proceed if the declaration is not `error`
176-
// BUG If we have an error in a subsequent load, the error message appears below the current page...
177-
RouteVerdict::NotFound => {
178-
checkpoint("not_found");
179-
if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state() {
180-
let initial_container = initial_container.unwrap();
181-
// We need to move the server-rendered content from its current container to the reactive container (otherwise Sycamore can't work with it properly)
182-
// If we're not hydrating, there's no point in moving anything over, we'll just fully re-render
183-
#[cfg(feature = "hydrate")]
184-
{
185-
let initial_html = initial_container.inner_html();
186-
container_rx_elem.set_inner_html(&initial_html);
187-
}
188-
initial_container.set_inner_html("");
189-
// Make the initial container invisible
190-
initial_container.set_attribute("style", "display: none;").unwrap();
191-
// Hydrate the error pages
192-
// Right now, we don't provide translators to any error pages that have come from the server
193-
error_pages.render_page(&url, status, &err, None, &container_rx_elem);
194-
} else {
195-
// This is an error from navigating within the app (probably the dev mistyped a link...), so we'll clear the page
196-
container_rx_elem.set_inner_html("");
197-
error_pages.render_page("", 404, "not found", None, &container_rx_elem);
198-
}
199-
},
200-
};
201-
}));
202-
}));
267+
Router(RouterProps::new(HistoryIntegration::new(), cloned!(on_route_change_props => move |route: ReadSignal<AppRoute>| {
268+
// Sycamore's reactivity is broken by a future, so we need to explicitly add the route to the reactive dependencies here
269+
// We do need the future though (otherwise `container_rx` doesn't link to anything until it's too late)
270+
let verdict = route.get().get_verdict().clone();
271+
on_route_change(verdict, on_route_change_props);
272+
203273
// This template is reactive, and will be updated as necessary
204274
// However, the server has already rendered initial load content elsewhere, so we move that into here as well in the app shell
205275
// The main reason for this is that the router only intercepts click events from its children
@@ -209,6 +279,6 @@ pub fn perseus_router<AppRoute: PerseusRoute<TemplateNodeType> + 'static>(
209279
p(id = "__perseus_route_announcer", aria_live = "assertive", role = "alert", style = ROUTE_ANNOUNCER_STYLES) { (route_announcement.get()) }
210280
}
211281
}
212-
}))
282+
})))
213283
}
214284
}

packages/perseus/src/router/router_state.rs

+29
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
1+
use super::RouteVerdict;
2+
use crate::templates::TemplateNodeType;
3+
use std::cell::RefCell;
4+
use std::rc::Rc;
15
use sycamore::prelude::{ReadSignal, Signal};
26

37
/// The state for the router.
48
#[derive(Clone, Debug)]
59
pub struct RouterState {
610
/// The router's current load state.
711
load_state: Signal<RouterLoadState>,
12+
/// The last route verdict. We can come back to this if we need to reload the current page without losing context etc.
13+
last_verdict: Rc<RefCell<Option<RouteVerdict<TemplateNodeType>>>>,
14+
/// A flip-flop `Signal`. Whenever this is changed, the router will reload the current page in the SPA style. As a user,
15+
/// you should rarely ever need to do this, but it's used internally in the thawing process.
16+
pub(crate) reload_commander: Signal<bool>,
817
}
918
impl Default for RouterState {
1019
/// Creates a default instance of the router state intended for server-side usage.
1120
fn default() -> Self {
1221
Self {
1322
load_state: Signal::new(RouterLoadState::Server),
23+
last_verdict: Rc::new(RefCell::new(None)),
24+
// It doesn't matter what we initialize this as, it's just for signalling
25+
reload_commander: Signal::new(true),
1426
}
1527
}
1628
}
@@ -23,6 +35,23 @@ impl RouterState {
2335
pub fn set_load_state(&self, new: RouterLoadState) {
2436
self.load_state.set(new);
2537
}
38+
/// Gets the last verdict.
39+
pub fn get_last_verdict(&self) -> Option<RouteVerdict<TemplateNodeType>> {
40+
(*self.last_verdict.borrow()).clone()
41+
}
42+
/// Sets the last verdict.
43+
pub fn set_last_verdict(&mut self, new: RouteVerdict<TemplateNodeType>) {
44+
let mut last_verdict = self.last_verdict.borrow_mut();
45+
*last_verdict = Some(new);
46+
}
47+
/// Orders the router to reload the current page as if you'd called `navigate()` to it (but that would do nothing). This
48+
/// enables reloading in an SPA style (but you should almost never need it).
49+
///
50+
/// Warning: if you're trying to rest your app, do NOT use this! Instead, reload the page fully through `web_sys`.
51+
pub fn reload(&self) {
52+
self.reload_commander
53+
.set(!*self.reload_commander.get_untracked())
54+
}
2655
}
2756

2857
/// 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).

packages/perseus/src/shell.rs

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::error_pages::ErrorPageData;
22
use crate::errors::*;
33
use crate::i18n::ClientTranslationsManager;
4-
use crate::router::{RouterLoadState, RouterState};
4+
use crate::router::{RouteVerdict, RouterLoadState, RouterState};
55
use crate::server::PageData;
66
use crate::state::PageStateStore;
77
use crate::state::{FrozenApp, GlobalState, ThawPrefs};
@@ -257,6 +257,9 @@ pub struct ShellProps {
257257
pub global_state: GlobalState,
258258
/// A previous frozen state to be gradully rehydrated. This should always be `None`, it only serves to provide continuity across templates.
259259
pub frozen_app: Rc<RefCell<Option<(FrozenApp, ThawPrefs)>>>,
260+
/// The current route verdict. This will be stored in context so that it can be used for possible reloads. Eventually,
261+
/// this will be made obsolete when Sycamore supports this natively.
262+
pub route_verdict: RouteVerdict<TemplateNodeType>,
260263
}
261264

262265
/// 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,14 +271,15 @@ pub async fn app_shell(
268271
template,
269272
was_incremental_match,
270273
locale,
271-
router_state,
274+
mut router_state,
272275
page_state_store,
273276
translations_manager,
274277
error_pages,
275278
initial_container,
276279
container_rx_elem,
277280
global_state: curr_global_state,
278281
frozen_app,
282+
route_verdict,
279283
}: ShellProps,
280284
) {
281285
checkpoint("app_shell_entry");
@@ -288,6 +292,7 @@ pub async fn app_shell(
288292
template_name: template.get_path(),
289293
path: path_with_locale.clone(),
290294
});
295+
router_state.set_last_verdict(route_verdict);
291296
// Get the global state if possible (we'll want this in all cases except errors)
292297
// 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`)
293298
let global_state = get_global_state();

0 commit comments

Comments
 (0)