Skip to content

Commit c232aed

Browse files
committed
feat: removed unnecessary panics and added custom panic handler system
The very few edge cases of server failure in which Perseus would panic have been rectified, and the user can now define a custom panic handler, e.g. for displaying a message to the user that tells them that the app has panicked (optimally with a button to reload).
1 parent 8e3d55f commit c232aed

File tree

7 files changed

+154
-43
lines changed

7 files changed

+154
-43
lines changed

packages/perseus/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ minify-html-onepass = "0.10.1"
4040
[target.'cfg(target_arch = "wasm32")'.dependencies]
4141
rexie = { version = "0.2", optional = true }
4242
js-sys = { version = "0.3", optional = true }
43+
# Note that this is not needed in production, but that can't be specified, so it will just be compiled away to nothing
4344
console_error_panic_hook = { version = "0.1.6", optional = true }
4445
# TODO review feature flags here
4546
web-sys = { version = "0.3", features = [ "Headers", "Navigator", "NodeList", "Request", "RequestInit", "RequestMode", "Response", "ReadableStream", "Window" ] }

packages/perseus/src/client.rs

+14-5
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ use crate::{
44
router::{perseus_router, PerseusRouterProps},
55
template::TemplateNodeType,
66
};
7+
use crate::{i18n::TranslationsManager, stores::MutableStore, PerseusAppBase};
78
use std::collections::HashMap;
89
use wasm_bindgen::JsValue;
910

10-
use crate::{i18n::TranslationsManager, stores::MutableStore, PerseusAppBase};
11-
1211
/// The entrypoint into the app itself. This will be compiled to Wasm and
1312
/// actually executed, rendering the rest of the app. Runs the app in the
1413
/// browser on the client-side. This is designed to be executed in a function
@@ -22,12 +21,22 @@ use crate::{i18n::TranslationsManager, stores::MutableStore, PerseusAppBase};
2221
pub fn run_client<M: MutableStore, T: TranslationsManager>(
2322
app: impl Fn() -> PerseusAppBase<TemplateNodeType, M, T>,
2423
) -> Result<(), JsValue> {
25-
let app = app();
24+
let mut app = app();
2625
let plugins = app.get_plugins();
26+
let panic_handler = app.take_panic_handler();
2727

2828
checkpoint("begin");
29-
// Panics should always go to the console
30-
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
29+
30+
// Handle panics (this works for unwinds and aborts)
31+
std::panic::set_hook(Box::new(move |panic_info| {
32+
// Print to the console in development
33+
#[cfg(debug_assertions)]
34+
console_error_panic_hook::hook(panic_info);
35+
// If the user wants a little warning dialogue, create that
36+
if let Some(panic_handler) = &panic_handler {
37+
panic_handler(panic_info);
38+
}
39+
}));
3140

3241
plugins
3342
.functional_actions

packages/perseus/src/errors.rs

+2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ pub enum ClientError {
7676
#[source]
7777
source: serde_json::Error,
7878
},
79+
#[error("server informed us that a valid locale was invald (this almost certainly requires a hard reload)")]
80+
ValidLocaleNotProvided { locale: String },
7981
#[error("the given path for preloading leads to a locale detection page; you probably wanted to wrap the path in `link!(...)`")]
8082
PreloadLocaleDetection,
8183
#[error("the given path for preloading was not found")]

packages/perseus/src/i18n/client_translations_manager.rs

+11-19
Original file line numberDiff line numberDiff line change
@@ -125,26 +125,18 @@ impl ClientTranslationsManager {
125125
let asset_url = format!("{}/.perseus/translations/{}", path_prefix, locale);
126126
// If this doesn't exist, then it's a 404 (we went here by explicit navigation
127127
// after checking the locale, so that's a bug)
128-
let translations_str = fetch(&asset_url).await;
128+
let translations_str = fetch(&asset_url).await?;
129129
let translator = match translations_str {
130-
Ok(translations_str) => match translations_str {
131-
Some(translations_str) => {
132-
// All good, turn the translations into a translator
133-
self.get_translator_for_translations_str(locale, &translations_str)?
134-
}
135-
// If we get a 404 for a supported locale, that's an exception
136-
None => panic!(
137-
"server returned 404 for translations for known supported locale '{}'",
138-
locale
139-
),
140-
},
141-
Err(err) => match err {
142-
not_ok_err @ ClientError::FetchError(FetchError::NotOk { .. }) => {
143-
return Err(not_ok_err)
144-
}
145-
// No other errors should be returned
146-
_ => panic!("expected 'AssetNotOk' error, found other unacceptable error"),
147-
},
130+
Some(translations_str) => {
131+
// All good, turn the translations into a translator
132+
self.get_translator_for_translations_str(locale, &translations_str)?
133+
}
134+
// If we get a 404 for a supported locale, that's an exception
135+
None => {
136+
return Err(ClientError::ValidLocaleNotProvided {
137+
locale: locale.to_string(),
138+
})
139+
}
148140
};
149141
// This caches and returns the translator
150142
Ok(self.cache_translator(translator))

packages/perseus/src/init.rs

+39-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::marker::PhantomData;
1818
use std::pin::Pin;
1919
#[cfg(not(target_arch = "wasm32"))]
2020
use std::sync::Arc;
21-
use std::{collections::HashMap, rc::Rc};
21+
use std::{collections::HashMap, panic::PanicInfo, rc::Rc};
2222
use sycamore::prelude::Scope;
2323
use sycamore::utils::hydrate::with_no_hydration_context;
2424
use sycamore::{
@@ -104,7 +104,6 @@ where
104104
/// The options for constructing a Perseus app. This `struct` will tie
105105
/// together all your code, declaring to Perseus where your templates,
106106
/// error pages, static content, etc. are.
107-
#[derive(Debug)]
108107
pub struct PerseusAppBase<G: Html, M: MutableStore, T: TranslationsManager> {
109108
/// The HTML ID of the root `<div>` element into which Perseus will be
110109
/// injected.
@@ -154,6 +153,10 @@ pub struct PerseusAppBase<G: Html, M: MutableStore, T: TranslationsManager> {
154153
/// here will only be used if it exists.
155154
#[cfg(not(target_arch = "wasm32"))]
156155
static_dir: String,
156+
/// A handler for panics on the client-side. This could create an arbitrary
157+
/// message for the user, or do anything else.
158+
#[cfg(target_arch = "wasm32")]
159+
panic_handler: Option<Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>>,
157160
// We need this on the client-side to account for the unused type parameters
158161
#[cfg(target_arch = "wasm32")]
159162
_marker: PhantomData<(M, T)>,
@@ -307,6 +310,8 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
307310
#[cfg(not(target_arch = "wasm32"))]
308311
static_dir: "./static".to_string(),
309312
#[cfg(target_arch = "wasm32")]
313+
panic_handler: None,
314+
#[cfg(target_arch = "wasm32")]
310315
_marker: PhantomData,
311316
}
312317
}
@@ -335,6 +340,7 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
335340
plugins: Rc::new(Plugins::new()),
336341
// Many users won't need anything fancy in the index view, so we provide a default
337342
index_view: DFLT_INDEX_VIEW.to_string(),
343+
panic_handler: None,
338344
_marker: PhantomData,
339345
}
340346
}
@@ -562,6 +568,29 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
562568
self.pss_max_size = val;
563569
self
564570
}
571+
/// Sets the browser-side panic handler for your app. This is a function
572+
/// that will be executed if your app panics (which should never be caused
573+
/// by Perseus unless something is seriously wrong, it's much more likely
574+
/// to come from your code, or third-party code). If this happens, your page
575+
/// would become totally uninteractive, with no warning to the user, since
576+
/// Wasm will simply abort. In such cases, it is strongly recommended to
577+
/// generate a warning message that notifies the user.
578+
///
579+
/// Note that there is no access within this function to Sycamore, page
580+
/// state, global state, or translators. Assume that your code has
581+
/// completely imploded when you write this function.
582+
///
583+
/// This has no default value.
584+
#[allow(unused_variables)]
585+
#[allow(unused_mut)]
586+
pub fn panic_handler(mut self, val: impl Fn(&PanicInfo) + Send + Sync + 'static) -> Self {
587+
#[cfg(target_arch = "wasm32")]
588+
{
589+
self.panic_handler = Some(Box::new(val));
590+
}
591+
self
592+
}
593+
565594
// Getters
566595
/// Gets the HTML ID of the `<div>` at which to insert Perseus.
567596
pub fn get_root(&self) -> String {
@@ -873,6 +902,14 @@ impl<G: Html, M: MutableStore, T: TranslationsManager> PerseusAppBase<G, M, T> {
873902

874903
scoped_static_aliases
875904
}
905+
/// Takes the user-set panic handler out and returns it as an owned value,
906+
/// allowing it to be used as an actual panic hook.
907+
#[cfg(target_arch = "wasm32")]
908+
pub fn take_panic_handler(
909+
&mut self,
910+
) -> Option<Box<dyn Fn(&PanicInfo) + Send + Sync + 'static>> {
911+
self.panic_handler.take()
912+
}
876913
}
877914

878915
/// The component that represents the entrypoint at which Perseus will inject

packages/perseus/src/router/get_initial_view.rs

+38-6
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,42 @@ pub(crate) fn get_initial_view(
111111
path: path_with_locale.clone(),
112112
});
113113
return InitialView::View(match &err {
114-
// These errors happen because we couldn't get a translator, so they certainly don't get one
115-
ClientError::FetchError(FetchError::NotOk { url, status, .. }) => error_pages.get_view_and_render_head(cx, url, *status, &fmt_err(&err), None),
116-
ClientError::FetchError(FetchError::SerFailed { url, .. }) => error_pages.get_view_and_render_head(cx, url, 500, &fmt_err(&err), None),
117-
ClientError::LocaleNotSupported { .. } => error_pages.get_view_and_render_head(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None),
118-
// No other errors should be returned
119-
_ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error")
114+
// These errors happen because we couldn't get a translator, so they
115+
// certainly don't get one
116+
ClientError::FetchError(FetchError::NotOk {
117+
url, status, ..
118+
}) => error_pages.get_view_and_render_head(
119+
cx,
120+
url,
121+
*status,
122+
&fmt_err(&err),
123+
None,
124+
),
125+
ClientError::FetchError(FetchError::SerFailed { url, .. }) => {
126+
error_pages.get_view_and_render_head(
127+
cx,
128+
url,
129+
500,
130+
&fmt_err(&err),
131+
None,
132+
)
133+
}
134+
ClientError::LocaleNotSupported { .. } => error_pages
135+
.get_view_and_render_head(
136+
cx,
137+
&format!("/{}/...", locale),
138+
404,
139+
&fmt_err(&err),
140+
None,
141+
),
142+
// No other errors should be returned, but we'll give any a 400
143+
_ => error_pages.get_view_and_render_head(
144+
cx,
145+
&format!("/{}/...", locale),
146+
400,
147+
&fmt_err(&err),
148+
None,
149+
),
120150
});
121151
}
122152
};
@@ -300,6 +330,8 @@ fn get_translations() -> Option<String> {
300330
fn get_head() -> String {
301331
let document = web_sys::window().unwrap().document().unwrap();
302332
// Get the current head
333+
// The server sends through a head, so we can guarantee that one is present (and
334+
// it's mandated for custom initial views)
303335
let head_node = document.query_selector("head").unwrap().unwrap();
304336
// Get all the elements after the head boundary (otherwise we'd be duplicating
305337
// the initial stuff)

packages/perseus/src/router/get_subsequent_view.rs

+49-11
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ pub(crate) struct GetSubsequentViewProps<'a> {
4141
/// Note that this will automatically update the router state just before it
4242
/// returns, meaning that any errors that may occur after this function has been
4343
/// called need to reset the router state to be an error.
44-
// TODO Eliminate all panics in this function
4544
pub(crate) async fn get_subsequent_view(
4645
GetSubsequentViewProps {
4746
cx,
@@ -108,12 +107,18 @@ pub(crate) async fn get_subsequent_view(
108107
pss.add_head(&path, page_data.head.to_string());
109108
Ok(page_data)
110109
}
111-
// If the page failed to serialize, an exception has occurred
110+
// If the page failed to serialize, it's a server error
112111
Err(err) => {
113112
router_state.set_load_state(RouterLoadState::ErrorLoaded {
114113
path: path_with_locale.clone(),
115114
});
116-
panic!("page data couldn't be serialized: '{}'", err)
115+
Err(error_pages.get_view_and_render_head(
116+
cx,
117+
&asset_url,
118+
500,
119+
&fmt_err(&err),
120+
None,
121+
))
117122
}
118123
}
119124
}
@@ -146,8 +151,14 @@ pub(crate) async fn get_subsequent_view(
146151
None,
147152
))
148153
}
149-
// No other errors should be returned
150-
_ => panic!("expected 'AssetNotOk' error, found other unacceptable error"),
154+
// No other errors should be returned, but we'll give any a 400
155+
_ => Err(error_pages.get_view_and_render_head(
156+
cx,
157+
&asset_url,
158+
400,
159+
&fmt_err(&err),
160+
None,
161+
)),
151162
}
152163
}
153164
}
@@ -194,12 +205,39 @@ pub(crate) async fn get_subsequent_view(
194205
path: path_with_locale.clone(),
195206
});
196207
match &err {
197-
// These errors happen because we couldn't get a translator, so they certainly don't get one
198-
ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.get_view_and_render_head(cx, url, *status, &fmt_err(&err), None),
199-
ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.get_view_and_render_head(cx, url, 500, &fmt_err(&err), None),
200-
ClientError::LocaleNotSupported { locale } => return error_pages.get_view_and_render_head(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None),
201-
// No other errors should be returned
202-
_ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error")
208+
// These errors happen because we couldn't get a translator, so they certainly don't
209+
// get one
210+
ClientError::FetchError(FetchError::NotOk { url, status, .. }) => {
211+
return error_pages.get_view_and_render_head(
212+
cx,
213+
url,
214+
*status,
215+
&fmt_err(&err),
216+
None,
217+
)
218+
}
219+
ClientError::FetchError(FetchError::SerFailed { url, .. }) => {
220+
return error_pages.get_view_and_render_head(cx, url, 500, &fmt_err(&err), None)
221+
}
222+
ClientError::LocaleNotSupported { locale } => {
223+
return error_pages.get_view_and_render_head(
224+
cx,
225+
&format!("/{}/...", locale),
226+
404,
227+
&fmt_err(&err),
228+
None,
229+
)
230+
}
231+
// No other errors should be returned, but we'll give any a 400
232+
_ => {
233+
return error_pages.get_view_and_render_head(
234+
cx,
235+
&format!("/{}/...", locale),
236+
400,
237+
&fmt_err(&err),
238+
None,
239+
)
240+
}
203241
}
204242
}
205243
};

0 commit comments

Comments
 (0)