From d4077272fe2849546a043a8ae73f723635bee8ea Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Sat, 20 Aug 2022 01:56:29 +0200 Subject: [PATCH] feat: redesigned app shell with support for hydration (#177) * feat: made perseus router hydrate on properly on initial page loads This improves performance substantially, especially on mobile and bandwidth-restricted devices, since interactivity requires no external network requests for fetching translations. * refactor: cleaned up unused imports Also moved some internal functions into more sensible places now that the app shell has been forked into subsequent/initial loads. * feat: added support for route announcer with new router This doesn't work yet, because the router state is still in limbo. * fix: prevented `/SERVER` url from appearing in dev This was possible if HSR took effect before interactivity. * fix: fixed router state with new router systems * fix(i18n): fixed locale redirection * chore: removed old dev logs * fix: fixed examples and appeased clippy * test: fixed checkpoints in integration tests * test: fixed `router_entry` checkpoints BREAKING CHANGE: - Added `ErrorLoaded { path }` case to `RouterLoadState` (which must now be matched) - Removed `page_visible testing` checkpoint (use `page_interactive` instead) - `router_entry checkpoint` is now only fired on subsequent loads --- examples/core/basic/tests/main.rs | 4 +- examples/core/custom_server/tests/main.rs | 4 +- .../core/freezing_and_thawing/tests/main.rs | 8 +- examples/core/global_state/tests/main.rs | 4 +- examples/core/idb_freezing/tests/main.rs | 8 +- examples/core/index_view/tests/main.rs | 4 +- examples/core/js_interop/tests/main.rs | 4 +- examples/core/plugins/tests/main.rs | 4 +- .../core/router_state/src/templates/index.rs | 3 + examples/core/rx_state/tests/main.rs | 6 +- examples/core/state_generation/tests/main.rs | 4 +- examples/core/unreactive/tests/main.rs | 2 +- .../perseus-actix-web/src/initial_load.rs | 15 +- packages/perseus-axum/src/initial_load.rs | 14 +- packages/perseus-macro/src/template_rx.rs | 6 +- packages/perseus-warp/src/initial_load.rs | 14 +- packages/perseus/src/client.rs | 33 +- packages/perseus/src/error_pages.rs | 124 ++-- packages/perseus/src/export.rs | 11 +- .../src/i18n/client_translations_manager.rs | 125 ++-- packages/perseus/src/i18n/locale_detector.rs | 13 +- packages/perseus/src/lib.rs | 4 +- packages/perseus/src/router/app_route.rs | 19 +- .../perseus/src/router/get_initial_view.rs | 323 ++++++++++ .../perseus/src/router/get_subsequent_view.rs | 198 ++++++ packages/perseus/src/router/mod.rs | 11 + .../perseus/src/router/router_component.rs | 312 +++++----- packages/perseus/src/router/router_state.rs | 6 + .../perseus/src/server/build_error_page.rs | 4 + packages/perseus/src/server/html_shell.rs | 33 +- packages/perseus/src/shell.rs | 568 ------------------ packages/perseus/src/state/global_state.rs | 2 + packages/perseus/src/template/render_ctx.rs | 6 +- packages/perseus/src/utils/checkpoint.rs | 80 +++ packages/perseus/src/utils/context.rs | 12 +- packages/perseus/src/utils/fetch.rs | 54 ++ packages/perseus/src/utils/mod.rs | 8 + 37 files changed, 1191 insertions(+), 859 deletions(-) create mode 100644 packages/perseus/src/router/get_initial_view.rs create mode 100644 packages/perseus/src/router/get_subsequent_view.rs delete mode 100644 packages/perseus/src/shell.rs create mode 100644 packages/perseus/src/utils/checkpoint.rs create mode 100644 packages/perseus/src/utils/fetch.rs diff --git a/examples/core/basic/tests/main.rs b/examples/core/basic/tests/main.rs index bc6b9ed33c..0a7b441c9b 100644 --- a/examples/core/basic/tests/main.rs +++ b/examples/core/basic/tests/main.rs @@ -10,7 +10,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // The greeting was passed through using build state wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); // For some reason, retrieving the inner HTML or text of a `` doens't @@ -23,7 +23,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "About."); diff --git a/examples/core/custom_server/tests/main.rs b/examples/core/custom_server/tests/main.rs index 2b336084ff..1be50a2558 100644 --- a/examples/core/custom_server/tests/main.rs +++ b/examples/core/custom_server/tests/main.rs @@ -9,7 +9,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); @@ -18,7 +18,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "About."); diff --git a/examples/core/freezing_and_thawing/tests/main.rs b/examples/core/freezing_and_thawing/tests/main.rs index 6a741d5a14..1d157e9d88 100644 --- a/examples/core/freezing_and_thawing/tests/main.rs +++ b/examples/core/freezing_and_thawing/tests/main.rs @@ -8,7 +8,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // Check the initials let mut page_state = c.find(Locator::Id("page_state")).await?; @@ -31,7 +31,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // Switch to the about page to demonstrate route restoration as well c.find(Locator::Id("about-link")).await?.click().await?; c.current_url().await?; - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Now press the freeze button and get the frozen app c.find(Locator::Id("freeze_button")).await?.click().await?; let frozen_app = c.find(Locator::Id("frozen_app")).await?.text().await?; @@ -43,7 +43,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // Check that the empty initials are restored assert_eq!( c.find(Locator::Id("page_state")).await?.text().await?, @@ -74,7 +74,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // And go back to the index page to check everything fully c.find(Locator::Id("index-link")).await?.click().await?; c.current_url().await?; - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Verify that everything has been correctly restored assert_eq!( c.find(Locator::Id("page_state")).await?.text().await?, diff --git a/examples/core/global_state/tests/main.rs b/examples/core/global_state/tests/main.rs index 0ffe513149..f5b4dea253 100644 --- a/examples/core/global_state/tests/main.rs +++ b/examples/core/global_state/tests/main.rs @@ -8,7 +8,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // The initial text should be "Hello World!" let mut greeting = c.find(Locator::Css("p")).await?; @@ -26,7 +26,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World! Extra text."); diff --git a/examples/core/idb_freezing/tests/main.rs b/examples/core/idb_freezing/tests/main.rs index 14e1100844..50f30006db 100644 --- a/examples/core/idb_freezing/tests/main.rs +++ b/examples/core/idb_freezing/tests/main.rs @@ -8,7 +8,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // Check the initials let mut page_state = c.find(Locator::Id("page_state")).await?; @@ -31,7 +31,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // Switch to the about page to demonstrate route restoration as well c.find(Locator::Id("about-link")).await?.click().await?; c.current_url().await?; - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Now press the freeze button (this will save to IDB, so we don't have to worry // about saving the output and typing it in later) c.find(Locator::Id("freeze_button")).await?.click().await?; @@ -43,7 +43,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // Check that the empty initials are restored assert_eq!( c.find(Locator::Id("page_state")).await?.text().await?, @@ -70,7 +70,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // And go back to the index page to check everything fully c.find(Locator::Id("index-link")).await?.click().await?; c.current_url().await?; - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Verify that everything has been correctly restored assert_eq!( c.find(Locator::Id("page_state")).await?.text().await?, diff --git a/examples/core/index_view/tests/main.rs b/examples/core/index_view/tests/main.rs index a133016f8b..f846135f89 100644 --- a/examples/core/index_view/tests/main.rs +++ b/examples/core/index_view/tests/main.rs @@ -10,7 +10,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // The greeting was passed through using build state wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); // Check the footer, the symbol of the index view having worked @@ -22,7 +22,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "About."); diff --git a/examples/core/js_interop/tests/main.rs b/examples/core/js_interop/tests/main.rs index bc6b9ed33c..0a7b441c9b 100644 --- a/examples/core/js_interop/tests/main.rs +++ b/examples/core/js_interop/tests/main.rs @@ -10,7 +10,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // The greeting was passed through using build state wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); // For some reason, retrieving the inner HTML or text of a `<title>` doens't @@ -23,7 +23,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "About."); diff --git a/examples/core/plugins/tests/main.rs b/examples/core/plugins/tests/main.rs index a2c6ccf52a..9e49770237 100644 --- a/examples/core/plugins/tests/main.rs +++ b/examples/core/plugins/tests/main.rs @@ -12,7 +12,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // The greeting was passed through using build state wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); // For some reason, retrieving the inner HTML or text of a `<title>` doens't @@ -25,7 +25,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); // Make sure the hardcoded text there exists let text = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(text, "Hey from a plugin!"); diff --git a/examples/core/router_state/src/templates/index.rs b/examples/core/router_state/src/templates/index.rs index 0328b10136..932e1c1200 100644 --- a/examples/core/router_state/src/templates/index.rs +++ b/examples/core/router_state/src/templates/index.rs @@ -16,6 +16,9 @@ pub fn router_state_page<G: Html>(cx: Scope) -> View<G> { path, } => format!("Loading {} (template: {}).", path, template_name), RouterLoadState::Server => "We're on the server.".to_string(), + // Since this code is running in a page, it's a little pointless to handle an error page, + // which would replace this page (we wouldn't be able to display anything if this happened) + RouterLoadState::ErrorLoaded { .. } => unreachable!(), }); view! { cx, diff --git a/examples/core/rx_state/tests/main.rs b/examples/core/rx_state/tests/main.rs index 45ecd41816..e3586007cc 100644 --- a/examples/core/rx_state/tests/main.rs +++ b/examples/core/rx_state/tests/main.rs @@ -8,7 +8,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); // The initial greeting should be to an empty string let mut greeting = c.find(Locator::Css("p")).await?; @@ -26,12 +26,12 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080/about")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); c.find(Locator::Id("index-link")).await?.click().await?; let url = c.current_url().await?; assert!(url.as_ref().starts_with("http://localhost:8080")); wait_for_checkpoint!("initial_state_not_present", 0, c); - wait_for_checkpoint!("page_visible", 1, c); + wait_for_checkpoint!("page_interactive", 1, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Greetings, Test User!"); diff --git a/examples/core/state_generation/tests/main.rs b/examples/core/state_generation/tests/main.rs index 5f8a994595..0b61f2c68a 100644 --- a/examples/core/state_generation/tests/main.rs +++ b/examples/core/state_generation/tests/main.rs @@ -111,7 +111,7 @@ async fn revalidation(c: &mut Client) -> Result<(), fantoccini::error::CmdError> // be different std::thread::sleep(std::time::Duration::from_secs(5)); c.refresh().await?; - wait_for_checkpoint!("router_entry", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let new_text = c.find(Locator::Css("p")).await?.text().await?; assert_ne!(text, new_text); @@ -135,7 +135,7 @@ async fn revalidation_and_incremental_generation( // be different std::thread::sleep(std::time::Duration::from_secs(5)); c.refresh().await?; - wait_for_checkpoint!("router_entry", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let new_text = c.find(Locator::Css("p")).await?.text().await?; assert_ne!(text, new_text); diff --git a/examples/core/unreactive/tests/main.rs b/examples/core/unreactive/tests/main.rs index b8a0e518a6..bbed6e1bd5 100644 --- a/examples/core/unreactive/tests/main.rs +++ b/examples/core/unreactive/tests/main.rs @@ -10,7 +10,7 @@ async fn main(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { // The greeting was passed through using build state wait_for_checkpoint!("initial_state_present", 0, c); - wait_for_checkpoint!("page_visible", 0, c); + wait_for_checkpoint!("page_interactive", 0, c); let greeting = c.find(Locator::Css("p")).await?.text().await?; assert_eq!(greeting, "Hello World!"); // For some reason, retrieving the inner HTML or text of a `<title>` doens't diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index 2b133123a4..d8e108ee52 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -57,6 +57,7 @@ pub async fn initial_load<M: MutableStore, T: TranslationsManager>( }; // Run the routing algorithms on the path to figure out which template we need + // (this *does* check if the locale is supported) let verdict = match_route_atomic(&path_slice, render_cfg.get_ref(), templates, &opts.locales); match verdict { // If this is the outcome, we know that the locale is supported and the like @@ -99,11 +100,23 @@ pub async fn initial_load<M: MutableStore, T: TranslationsManager>( return html_err(err_to_status_code(&err), &fmt_err(&err)); } }; + // Get the translations to interpolate into the page + let translations = translations_manager + .get_translations_str_for_locale(locale) + .await; + let translations = match translations { + Ok(translations) => translations, + // We know for sure that this locale is supported, so there's been an internal + // server error if it can't be found + Err(err) => { + return html_err(500, &fmt_err(&err)); + } + }; let final_html = html_shell .get_ref() .clone() - .page_data(&page_data, &global_state) + .page_data(&page_data, &global_state, &translations) .to_string(); let mut http_res = HttpResponse::Ok(); diff --git a/packages/perseus-axum/src/initial_load.rs b/packages/perseus-axum/src/initial_load.rs index c0393b4bd4..6ae906758d 100644 --- a/packages/perseus-axum/src/initial_load.rs +++ b/packages/perseus-axum/src/initial_load.rs @@ -95,11 +95,23 @@ pub async fn initial_load_handler<M: MutableStore, T: TranslationsManager>( return html_err(err_to_status_code(&err), &fmt_err(&err)); } }; + // Get the translations to interpolate into the page + let translations = translations_manager + .get_translations_str_for_locale(locale) + .await; + let translations = match translations { + Ok(translations) => translations, + // We know for sure that this locale is supported, so there's been an internal + // server error if it can't be found + Err(err) => { + return html_err(500, &fmt_err(&err)); + } + }; let final_html = html_shell .as_ref() .clone() - .page_data(&page_data, &global_state) + .page_data(&page_data, &global_state, &translations) .to_string(); // http_res.content_type("text/html"); diff --git a/packages/perseus-macro/src/template_rx.rs b/packages/perseus-macro/src/template_rx.rs index e33d207161..9d23ea9a6a 100644 --- a/packages/perseus-macro/src/template_rx.rs +++ b/packages/perseus-macro/src/template_rx.rs @@ -153,7 +153,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { // add it to the page state store let state_arg = &fn_args[1]; let rx_props_ty = match state_arg { - FnArg::Typed(PatType { ty, .. }) => make_mid(&**ty), + FnArg::Typed(PatType { ty, .. }) => make_mid(ty), FnArg::Receiver(_) => unreachable!(), }; // There's also a second argument for the global state, which we'll deserialize @@ -163,7 +163,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { // variable (this should be fine?) let global_state_arg = &fn_args[2]; let (global_state_arg_pat, global_state_rx) = match global_state_arg { - FnArg::Typed(PatType { pat, ty, .. }) => (pat, make_mid(&**ty)), + FnArg::Typed(PatType { pat, ty, .. }) => (pat, make_mid(ty)), FnArg::Receiver(_) => unreachable!(), }; let name_string = name.to_string(); @@ -264,7 +264,7 @@ pub fn template_impl(input: TemplateFn) -> TokenStream { // add it to the page state store let arg = &fn_args[1]; let rx_props_ty = match arg { - FnArg::Typed(PatType { ty, .. }) => make_mid(&**ty), + FnArg::Typed(PatType { ty, .. }) => make_mid(ty), FnArg::Receiver(_) => unreachable!(), }; let name_string = name.to_string(); diff --git a/packages/perseus-warp/src/initial_load.rs b/packages/perseus-warp/src/initial_load.rs index 3828ddbf72..89f60d2d13 100644 --- a/packages/perseus-warp/src/initial_load.rs +++ b/packages/perseus-warp/src/initial_load.rs @@ -86,11 +86,23 @@ pub async fn initial_load_handler<M: MutableStore, T: TranslationsManager>( return html_err(err_to_status_code(&err), &fmt_err(&err)); } }; + // Get the translations to interpolate into the page + let translations = translations_manager + .get_translations_str_for_locale(locale) + .await; + let translations = match translations { + Ok(translations) => translations, + // We know for sure that this locale is supported, so there's been an internal + // server error if it can't be found + Err(err) => { + return html_err(500, &fmt_err(&err)); + } + }; let final_html = html_shell .as_ref() .clone() - .page_data(&page_data, &global_state) + .page_data(&page_data, &global_state, &translations) .to_string(); let mut http_res = Response::builder().status(200); diff --git a/packages/perseus/src/client.rs b/packages/perseus/src/client.rs index 3384182561..753f947fab 100644 --- a/packages/perseus/src/client.rs +++ b/packages/perseus/src/client.rs @@ -2,9 +2,9 @@ use crate::{ checkpoint, plugins::PluginAction, router::{perseus_router, PerseusRouterProps}, - shell::get_render_cfg, template::TemplateNodeType, }; +use std::collections::HashMap; use wasm_bindgen::JsValue; use crate::{i18n::TranslationsManager, stores::MutableStore, PerseusAppBase}; @@ -53,8 +53,16 @@ pub fn run_client<M: MutableStore, T: TranslationsManager>( render_cfg: get_render_cfg().expect("render configuration invalid or not injected"), }; + // At this point, the user can already see something from the server-side + // rendering, so we now have time to figure out exactly what to render. + // Having done that, we can render/hydrate, depending on the feature flags. + // All that work is done inside the router. + // This top-level context is what we use for everything, allowing page state to // be registered and stored for the lifetime of the app + #[cfg(feature = "hydrate")] + sycamore::hydrate_to(move |cx| perseus_router(cx, router_props), &root); + #[cfg(not(feature = "hydrate"))] sycamore::render_to(move |cx| perseus_router(cx, router_props), &root); Ok(()) @@ -63,3 +71,26 @@ pub fn run_client<M: MutableStore, T: TranslationsManager>( /// A convenience type wrapper for the type returned by nearly all client-side /// entrypoints. pub type ClientReturn = Result<(), JsValue>; + +/// Gets the render configuration from the JS global variable +/// `__PERSEUS_RENDER_CFG`, which should be inlined by the server. This will +/// return `None` on any error (not found, serialization failed, etc.), which +/// should reasonably lead to a `panic!` in the caller. +fn get_render_cfg() -> Option<HashMap<String, String>> { + let val_opt = web_sys::window().unwrap().get("__PERSEUS_RENDER_CFG"); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return None, + }; + // The object should only actually contain the string value that was injected + let cfg_str = match js_obj.as_string() { + Some(cfg_str) => cfg_str, + None => return None, + }; + let render_cfg = match serde_json::from_str::<HashMap<String, String>>(&cfg_str) { + Ok(render_cfg) => render_cfg, + Err(_) => return None, + }; + + Some(render_cfg) +} diff --git a/packages/perseus/src/error_pages.rs b/packages/perseus/src/error_pages.rs index af3bbb140f..e544775a7d 100644 --- a/packages/perseus/src/error_pages.rs +++ b/packages/perseus/src/error_pages.rs @@ -2,16 +2,12 @@ use crate::translator::Translator; use crate::Html; #[cfg(not(target_arch = "wasm32"))] use crate::SsrNode; -#[cfg(target_arch = "wasm32")] -use crate::{DomNode, HydrateNode}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::rc::Rc; use sycamore::prelude::Scope; use sycamore::view; use sycamore::view::View; -#[cfg(target_arch = "wasm32")] -use web_sys::Element; /// The callback to a template the user must provide for error pages. This is /// passed the status code, the error message, the URL of the problematic asset, @@ -98,69 +94,81 @@ impl<G: Html> ErrorPages<G> { false => &self.fallback, } } -} -#[cfg(target_arch = "wasm32")] -impl ErrorPages<DomNode> { - /// Renders the appropriate error page to the given DOM container. - pub fn render_page( - &self, - cx: Scope, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - container: &Element, - ) { - let template_fn = self.get_template_fn(status); - // Render that to the given container - sycamore::render_to( - |_| template_fn(cx, url.to_string(), status, err.to_string(), translator), - container, - ); - } -} -#[cfg(target_arch = "wasm32")] -impl ErrorPages<HydrateNode> { - /// Hydrates the appropriate error page to the given DOM container. This is - /// used for when an error page is rendered by the server and then needs - /// interactivity. - pub fn hydrate_page( + /// Gets the `View<G>` to render. + pub fn get_view( &self, cx: Scope, url: &str, status: u16, err: &str, translator: Option<Rc<Translator>>, - container: &Element, - ) { - let template_fn = self.get_template_fn(status); - let hydrate_view = template_fn(cx, url.to_string(), status, err.to_string(), translator); - // TODO Now convert that `HydrateNode` to a `DomNode` - let dom_view = hydrate_view; - // Render that to the given container - sycamore::hydrate_to(|_| dom_view, container); - } - /// Renders the appropriate error page to the given DOM container. This is - /// implemented on `HydrateNode` to avoid having to have two `Html` type - /// parameters everywhere (one for templates and one for error pages). - // TODO Convert from a `HydrateNode` to a `DomNode` - pub fn render_page( - &self, - cx: Scope, - url: &str, - status: u16, - err: &str, - translator: Option<Rc<Translator>>, - container: &Element, - ) { + ) -> View<G> { let template_fn = self.get_template_fn(status); - // Render that to the given container - sycamore::hydrate_to( - |_| template_fn(cx, url.to_string(), status, err.to_string(), translator), - container, - ); + template_fn(cx, url.to_string(), status, err.to_string(), translator) } } +// #[cfg(target_arch = "wasm32")] +// impl ErrorPages<DomNode> { +// /// Renders the appropriate error page to the given DOM container. +// pub fn render_page( +// &self, +// cx: Scope, +// url: &str, +// status: u16, +// err: &str, +// translator: Option<Rc<Translator>>, +// container: &Element, +// ) { +// let template_fn = self.get_template_fn(status); +// // Render that to the given container +// sycamore::render_to( +// |_| template_fn(cx, url.to_string(), status, err.to_string(), +// translator), container, +// ); +// } +// } +// #[cfg(target_arch = "wasm32")] +// impl ErrorPages<HydrateNode> { +// /// Hydrates the appropriate error page to the given DOM container. This +// is /// used for when an error page is rendered by the server and then +// needs /// interactivity. +// pub fn hydrate_page( +// &self, +// cx: Scope, +// url: &str, +// status: u16, +// err: &str, +// translator: Option<Rc<Translator>>, +// container: &Element, +// ) { +// let template_fn = self.get_template_fn(status); +// let hydrate_view = template_fn(cx, url.to_string(), status, +// err.to_string(), translator); // TODO Now convert that `HydrateNode` +// to a `DomNode` let dom_view = hydrate_view; +// // Render that to the given container +// sycamore::hydrate_to(|_| dom_view, container); +// } +// /// Renders the appropriate error page to the given DOM container. This +// is /// implemented on `HydrateNode` to avoid having to have two `Html` +// type /// parameters everywhere (one for templates and one for error +// pages). // TODO Convert from a `HydrateNode` to a `DomNode` +// pub fn render_page( +// &self, +// cx: Scope, +// url: &str, +// status: u16, +// err: &str, +// translator: Option<Rc<Translator>>, +// container: &Element, +// ) { +// let template_fn = self.get_template_fn(status); +// // Render that to the given container +// sycamore::hydrate_to( +// |_| template_fn(cx, url.to_string(), status, err.to_string(), +// translator), container, +// ); +// } +// } #[cfg(not(target_arch = "wasm32"))] impl ErrorPages<SsrNode> { /// Renders the error page to a string. This should then be hydrated on the diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index 54928902be..afcc2bf4e7 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -85,6 +85,7 @@ pub async fn export_app<T: TranslationsManager>( immutable_store, path_prefix.to_string(), global_state, + translations_manager, ); export_futs.push(fut); } @@ -128,6 +129,7 @@ pub async fn create_translation_file( } /// Exports a single path within a template. +#[allow(clippy::too_many_arguments)] pub async fn export_path( (path, template_path): (String, String), templates: &TemplateMap<SsrNode>, @@ -136,6 +138,7 @@ pub async fn export_path( immutable_store: &ImmutableStore, path_prefix: String, global_state: &Option<String>, + translations_manager: &impl TranslationsManager, ) -> Result<(), ServerError> { // We need the encoded path to reference flattened build artifacts // But we don't create a flattened system with exporting, everything is properly @@ -193,12 +196,16 @@ pub async fn export_path( immutable_store, ) .await?; + // Get the translations string for this locale + let translations = translations_manager + .get_translations_str_for_locale(locale.to_string()) + .await?; // Create a full HTML file from those that can be served for initial loads // The build process writes these with a dummy default locale even though we're // not using i18n let full_html = html_shell .clone() - .page_data(&page_data, global_state) + .page_data(&page_data, global_state, &translations) .to_string(); immutable_store .write( @@ -229,7 +236,7 @@ pub async fn export_path( // not using i18n let full_html = html_shell .clone() - .page_data(&page_data, global_state) + .page_data(&page_data, global_state, "") .to_string(); // We don't add an extension because this will be queried directly by the // browser diff --git a/packages/perseus/src/i18n/client_translations_manager.rs b/packages/perseus/src/i18n/client_translations_manager.rs index 8a2cdb63c8..1112fc08a7 100644 --- a/packages/perseus/src/i18n/client_translations_manager.rs +++ b/packages/perseus/src/i18n/client_translations_manager.rs @@ -1,7 +1,7 @@ use super::Locales; use crate::errors::*; use crate::i18n::Translator; -use crate::shell::fetch; +use crate::utils::fetch; use crate::utils::get_path_prefix_client; use std::cell::RefCell; use std::rc::Rc; @@ -30,23 +30,97 @@ impl ClientTranslationsManager { locales: locales.clone(), } } - /// Gets an `&'static Translator` for the given locale. This will use the - /// internally cached `Translator` if possible, and will otherwise fetch - /// the translations from the server. This manages mutability for caching - /// internally. - pub async fn get_translator_for_locale<'a>( - &'a self, - locale: &'a str, - ) -> Result<Translator, ClientError> { - let path_prefix = get_path_prefix_client(); + /// An internal preflight check performed before getting a translator. This + /// consists of making sure the locale is supported, and that the app is + /// actually using i18n. If i18n is not being used, then this will + /// return a dummy translator on its own, and no further computation should + /// be performed. If you need to fetch a translator after calling this, then + /// you should be sure to cache it. + /// + /// This will also return the cached translator if possible. + fn preflight_check(&self, locale: &str) -> Result<Option<Translator>, ClientError> { // Check if we've already cached let mut cached_translator = self.cached_translator.borrow_mut(); if cached_translator.is_some() && cached_translator.as_ref().unwrap().get_locale() == locale { - Ok(cached_translator.as_ref().unwrap().clone()) + Ok(Some(cached_translator.as_ref().unwrap().clone())) } else { // Check if the locale is supported and we're actually using i18n if self.locales.is_supported(locale) && self.locales.using_i18n { + // We're clear to fetch a translator for this locale + Ok(None) + } else if !self.locales.using_i18n { + // If we aren't even using i18n, then it would be pointless to fetch + // translations + let translator = Translator::new("xx-XX".to_string(), "".to_string()).unwrap(); + // Cache that translator + *cached_translator = Some(translator); + // Now return that + Ok(Some(cached_translator.as_ref().unwrap().clone())) + } else { + Err(ClientError::LocaleNotSupported { + locale: locale.to_string(), + }) + } + } + } + /// Caches the given translator internally for future use without needing to + /// make network requests. + /// + /// This consumes the given translator, and then re-fetches it from the + /// cache. + fn cache_translator(&self, translator: Translator) -> Translator { + let mut cached_translator = self.cached_translator.borrow_mut(); + *cached_translator = Some(translator); + cached_translator.as_ref().unwrap().clone() + } + + /// Gets a `Translator` for the given locale, using the given translations + /// string. This is intended to be used when fetching the translations + /// string from the window variable provided by the server for initial + /// loads. + /// + /// Note that this function automatically caches the translator it creates. + pub fn get_translator_for_translations_str( + &self, + locale: &str, + translations_str: &str, + ) -> Result<Translator, ClientError> { + match self.preflight_check(locale)? { + Some(translator) => Ok(translator), + // If we're clear to create the translator (i.e. it wasn't cached and the locale is + // supported), do so + None => { + let translator = + match Translator::new(locale.to_string(), translations_str.to_string()) { + Ok(translator) => translator, + Err(err) => { + return Err(FetchError::SerFailed { + url: "*".to_string(), + source: err.into(), + } + .into()) + } + }; + // This caches and returns the translator + Ok(self.cache_translator(translator)) + } + } + } + /// Gets a `Translator` for the given locale. This will use the + /// internally cached `Translator` if possible, and will otherwise fetch + /// the translations from the server. This manages mutability for caching + /// internally. + pub async fn get_translator_for_locale<'a>( + &'a self, + locale: &'a str, + ) -> Result<Translator, ClientError> { + match self.preflight_check(locale)? { + Some(translator) => Ok(translator), + // If we're clear to fetch the translator (i.e. it wasn't cached and the locale is + // supported), do so + None => { + let path_prefix = get_path_prefix_client(); // Get the translations data let asset_url = format!("{}/.perseus/translations/{}", path_prefix, locale); // If this doesn't exist, then it's a 404 (we went here by explicit navigation @@ -56,16 +130,7 @@ impl ClientTranslationsManager { Ok(translations_str) => match translations_str { Some(translations_str) => { // All good, turn the translations into a translator - match Translator::new(locale.to_string(), translations_str) { - Ok(translator) => translator, - Err(err) => { - return Err(FetchError::SerFailed { - url: asset_url, - source: err.into(), - } - .into()) - } - } + self.get_translator_for_translations_str(locale, &translations_str)? } // If we get a 404 for a supported locale, that's an exception None => panic!( @@ -81,22 +146,8 @@ impl ClientTranslationsManager { _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), }, }; - // Cache that translator - *cached_translator = Some(translator); - // Now return that - Ok(cached_translator.as_ref().unwrap().clone()) - } else if !self.locales.using_i18n { - // If we aren't even using i18n, then it would be pointless to fetch - // translations - let translator = Translator::new("xx-XX".to_string(), "".to_string()).unwrap(); - // Cache that translator - *cached_translator = Some(translator); - // Now return that - Ok(cached_translator.as_ref().unwrap().clone()) - } else { - Err(ClientError::LocaleNotSupported { - locale: locale.to_string(), - }) + // This caches and returns the translator + Ok(self.cache_translator(translator)) } } } diff --git a/packages/perseus/src/i18n/locale_detector.rs b/packages/perseus/src/i18n/locale_detector.rs index 16f7f1977a..4903e66330 100644 --- a/packages/perseus/src/i18n/locale_detector.rs +++ b/packages/perseus/src/i18n/locale_detector.rs @@ -1,6 +1,8 @@ use super::Locales; +use crate::template::TemplateNodeType; use crate::utils::get_path_prefix_client; use sycamore::rt::Reflect; +use sycamore::view::View; use wasm_bindgen::JsValue; /// Detects which locale the user should be served and redirects appropriately. @@ -9,8 +11,10 @@ use wasm_bindgen::JsValue; /// browser i18n settings). Any pages that direct to this should be explicitly /// excluded from search engines (they don't show anything until redirected). /// This is guided by [RFC 4647](https://www.rfc-editor.org/rfc/rfc4647.txt), but is not yet fully compliant (only supports `xx-XX` form locales). -/// Note that this bypasses Sycamore's routing logic and triggers a full reload. -pub(crate) fn detect_locale(url: String, locales: &Locales) { +/// +/// Note that this does not actually redirect on its own, it merely provides an +/// argument for `sycamore_router::navigate_replace()`. +pub(crate) fn detect_locale(url: String, locales: &Locales) -> String { // If nothing matches, we'll use the default locale let mut locale = locales.default.clone(); @@ -67,9 +71,8 @@ pub(crate) fn detect_locale(url: String, locales: &Locales) { &JsValue::undefined(), ) .unwrap(); - // Imperatively navigate to the localized route - // This certainly shouldn't fail... - sycamore_router::navigate_replace(new_loc); + + new_loc.to_string() } /// The possible outcomes of trying to match a locale. diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index 43abe7fed3..291b24518e 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -62,8 +62,6 @@ mod export; mod init; mod macros; mod page_data; -#[cfg(target_arch = "wasm32")] -mod shell; mod translator; // The rest of this file is devoted to module structuring @@ -95,7 +93,7 @@ pub use crate::{ }; // Browser-side only #[cfg(target_arch = "wasm32")] -pub use crate::shell::checkpoint; +pub use crate::utils::checkpoint; #[cfg(all(feature = "client-helpers", target_arch = "wasm32"))] pub use client::{run_client, ClientReturn}; diff --git a/packages/perseus/src/router/app_route.rs b/packages/perseus/src/router/app_route.rs index 5afe43c6b9..1f0047004f 100644 --- a/packages/perseus/src/router/app_route.rs +++ b/packages/perseus/src/router/app_route.rs @@ -1,26 +1,29 @@ use super::{match_route, RouteVerdict}; -use crate::{i18n::Locales, template::TemplateMap, Html}; +use crate::{ + i18n::Locales, + template::{TemplateMap, TemplateNodeType}, +}; use std::collections::HashMap; use sycamore_router::Route; /// The Perseus route system, which implements Sycamore `Route`, but adds /// additional data for Perseus' processing system. -pub(crate) struct PerseusRoute<G: Html> { +pub(crate) struct PerseusRoute { /// The current route verdict. The initialization value of this is /// completely irrelevant (it will be overriden immediately by the internal /// routing logic). - pub verdict: RouteVerdict<G>, + pub verdict: RouteVerdict<TemplateNodeType>, /// The app's render configuration. pub render_cfg: HashMap<String, String>, /// The templates the app is using. - pub templates: TemplateMap<G>, + pub templates: TemplateMap<TemplateNodeType>, /// The app's i18n configuration. pub locales: Locales, } // Sycamore would only use this if we were processing dynamic routes, which // we're not In other words, it's fine that these values would break everything // if they were used, they're just compiler appeasement -impl<G: Html> Default for PerseusRoute<G> { +impl Default for PerseusRoute { fn default() -> Self { Self { verdict: RouteVerdict::NotFound, @@ -34,13 +37,13 @@ impl<G: Html> Default for PerseusRoute<G> { } } } -impl<G: Html> PerseusRoute<G> { +impl PerseusRoute { /// Gets the current route verdict. - pub fn get_verdict(&self) -> &RouteVerdict<G> { + pub fn get_verdict(&self) -> &RouteVerdict<TemplateNodeType> { &self.verdict } } -impl<G: Html> Route for PerseusRoute<G> { +impl Route for PerseusRoute { fn match_route(&self, path: &[&str]) -> Self { let verdict = match_route(path, &self.render_cfg, &self.templates, &self.locales); Self { diff --git a/packages/perseus/src/router/get_initial_view.rs b/packages/perseus/src/router/get_initial_view.rs new file mode 100644 index 0000000000..328c3090e2 --- /dev/null +++ b/packages/perseus/src/router/get_initial_view.rs @@ -0,0 +1,323 @@ +use crate::error_pages::ErrorPageData; +use crate::errors::*; +use crate::i18n::{detect_locale, ClientTranslationsManager, Locales}; +use crate::router::match_route; +use crate::router::{RouteInfo, RouteVerdict, RouterLoadState, RouterState}; +use crate::template::{PageProps, TemplateMap, TemplateNodeType}; +use crate::utils::checkpoint; +use crate::ErrorPages; +use fmterr::fmt_err; +use std::collections::HashMap; +use sycamore::prelude::*; +use sycamore::rt::Reflect; // We can piggyback off Sycamore to avoid bringing in `js_sys` +use wasm_bindgen::JsValue; + +pub(crate) struct GetInitialViewProps<'a, 'cx> { + /// The app's reactive scope. + pub cx: Scope<'cx>, + /// The path we're rendering for (not the template path, the full path, + /// though parsed a little). + pub path: String, + /// The router state. + pub router_state: RouterState, + /// A *client-side* translations manager to use (this manages caching + /// translations). + pub translations_manager: &'a ClientTranslationsManager, + /// The error pages, for use if something fails. + pub error_pages: &'a ErrorPages<TemplateNodeType>, + /// The locales settings the app is using. + pub locales: &'a Locales, + /// The templates the app is using. + pub templates: &'a TemplateMap<TemplateNodeType>, + /// The render configuration of the app (which lays out routing information, + /// among other things). + pub render_cfg: &'a HashMap<String, String>, +} + +/// Gets the initial view that we should display when the app first loads. This +/// doesn't need to be asynchronous, since initial loads provide everything +/// necessary for hydration in one single HTML file (including state and +/// translator sources). +/// +/// Note that this function can only be run once, since it will delete the +/// initial state infrastructure from the page entirely. If this function is run +/// without that infrastructure being present, an error page will be rendered. +/// +/// Note that this will automatically update the router state just before it +/// returns, meaning that any errors that may occur after this function has been +/// called need to reset the router state to be an error. +pub(crate) fn get_initial_view( + GetInitialViewProps { + cx, + path, + mut router_state, + translations_manager, + error_pages, + locales, + templates, + render_cfg, + }: GetInitialViewProps<'_, '_>, +) -> InitialView { + // Start by figuring out what template we should be rendering + let path_segments = path + .split('/') + .filter(|s| !s.is_empty()) + .collect::<Vec<&str>>(); // This parsing is identical to the Sycamore router's + let verdict = match_route(&path_segments, &render_cfg, &templates, &locales); + match &verdict { + RouteVerdict::Found(RouteInfo { + path, + template, + locale, + // Since we're not requesting anything from the server, we don't need to worry about + // whether it's an incremental match or not + was_incremental_match: _, + }) => InitialView::View({ + let path_with_locale = match locale.as_str() { + "xx-XX" => path.clone(), + locale => format!("{}/{}", locale, &path), + }; + // Update the router state + router_state.set_load_state(RouterLoadState::Loading { + template_name: template.get_path(), + path: path_with_locale.clone(), + }); + router_state.set_last_verdict(verdict.clone()); + + // Get the initial state and decide what to do from that + let initial_state = get_initial_state(); + match initial_state { + InitialState::Present(state) => { + checkpoint("initial_state_present"); + let global_state = get_global_state(); + // Unset the initial state variable so we perform subsequent renders correctly + // This monstrosity is needed until `web-sys` adds a `.set()` method on `Window` + // We don't do this for the global state because it should hang around + // uninitialized until a template wants it (if we remove it before then, we're + // stuffed) + Reflect::set( + &JsValue::from(web_sys::window().unwrap()), + &JsValue::from("__PERSEUS_INITIAL_STATE"), + &JsValue::undefined(), + ) + .unwrap(); + + // Get the translator from the page (this has to exist, or the server stuffed + // up); doing this without a network request minimizes + // the time to interactivity (improving UX drastically), while meaning that we + // never have to fetch translations separately unless the user switches locales + let translations_str = match get_translations() { + Some(translations_str) => translations_str, + None => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + return InitialView::View(error_pages.get_view( + cx, + "*", + 500, + "expected translations in global variable, but none found", + None, + )); + } + }; + let translator = translations_manager + .get_translator_for_translations_str(&locale, &translations_str); + let translator = match translator { + Ok(translator) => translator, + Err(err) => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + return InitialView::View(match &err { + // These errors happen because we couldn't get a translator, so they certainly don't get one + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => error_pages.get_view(cx, url, *status, &fmt_err(&err), None), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => error_pages.get_view(cx, url, 500, &fmt_err(&err), None), + ClientError::LocaleNotSupported { .. } => error_pages.get_view(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None), + // No other errors should be returned + _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") + }); + } + }; + + let path = template.get_path(); + let page_props = PageProps { + path: path_with_locale.clone(), + state, + global_state, + }; + // Pre-emptively declare the page interactive 9since all we do from this point + // is hydrate + checkpoint("page_interactive"); + // Update the router state + router_state.set_load_state(RouterLoadState::Loaded { + template_name: path, + path: path_with_locale, + }); + // Return the actual template, for rendering/hydration + template.render_for_template_client(page_props, cx, translator) + } + // We have an error that the server sent down, so we should just return that error + // view + InitialState::Error(ErrorPageData { url, status, err }) => { + checkpoint("initial_state_error"); + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + error_pages.get_view(cx, &url, status, &err, None) + } + // The entire purpose of this function is to work with the initial state, so if this + // is true, then we have a problem Theoretically, this should never + // happen... (but I've seen magical infinite loops that crash browsers, so I'm + // hedging my bets) + InitialState::NotPresent => { + checkpoint("initial_state_error"); + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + error_pages.get_view(cx, "*", 400, "expected initial state render, found subsequent load (highly likely to be a core perseus bug)", None) + } + } + }), + // 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) => { + InitialView::Redirect(detect_locale(path.clone(), &locales)) + } + RouteVerdict::NotFound => InitialView::View({ + checkpoint("not_found"); + if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state() { + router_state.set_load_state(RouterLoadState::ErrorLoaded { path: url.clone() }); + // If this is an error from an initial state page, then we'll hydrate whatever's + // already there + // + // Since this page has come from the server, anything could have happened, so we + // provide no translator (and one certainly won't exist in context) + error_pages.get_view(cx, &url, status, &err, None) + } else { + // TODO Update the router state + // router_state.set_load_state(RouterLoadState::ErrorLoaded { + // path: path_with_locale.clone() + // }); + // Given that were only handling the initial load, this should really never + // happen... + error_pages.get_view(cx, "", 404, "not found", None) + } + }), + } +} + +/// A representation of the possible outcomes of getting the view for the +/// initial load. +pub(crate) enum InitialView { + /// A view is available to be rendered/hydrated. + View(View<TemplateNodeType>), + /// We need to redirect somewhere else, and the path to redirect to is + /// attached. + /// + /// Currently, this is only used by locale redirection, though this could + /// theoretically also be used for server-level reloads, if those + /// directives are ever supported. + Redirect(String), +} + +/// A representation of whether or not the initial state was present. If it was, +/// it could be `None` (some templates take no state), and if not, then this +/// isn't an initial load, and we need to request the page from the server. It +/// could also be an error that the server has rendered. +#[derive(Debug)] +pub(crate) enum InitialState { + /// A non-error initial state has been injected. This could be `None`, since + /// not all pages have state. + Present(Option<String>), + /// An initial state has been injected that indicates an error. + Error(ErrorPageData), + /// No initial state has been injected (or if it has, it's been deliberately + /// unset). + NotPresent, +} + +/// Gets the initial state injected by the server, if there was any. This is +/// used to differentiate initial loads from subsequent ones, which have +/// different log chains to prevent double-trips (a common SPA problem). +pub(crate) fn get_initial_state() -> InitialState { + let val_opt = web_sys::window().unwrap().get("__PERSEUS_INITIAL_STATE"); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return InitialState::NotPresent, + }; + // The object should only actually contain the string value that was injected + let state_str = match js_obj.as_string() { + Some(state_str) => state_str, + None => return InitialState::NotPresent, + }; + // On the server-side, we encode a `None` value directly (otherwise it will be + // some convoluted stringified JSON) + if state_str == "None" { + InitialState::Present(None) + } else if state_str.starts_with("error-") { + // We strip the prefix and escape any tab/newline control characters (inserted + // by `fmterr`) Any others are user-inserted, and this is documented + let err_page_data_str = state_str + .strip_prefix("error-") + .unwrap() + .replace('\n', "\\n") + .replace('\t', "\\t"); + // There will be error page data encoded after `error-` + let err_page_data = match serde_json::from_str::<ErrorPageData>(&err_page_data_str) { + Ok(render_cfg) => render_cfg, + // If there's a serialization error, we'll create a whole new error (500) + Err(err) => ErrorPageData { + url: "[current]".to_string(), + status: 500, + err: format!("couldn't serialize error from server: '{}'", err), + }, + }; + InitialState::Error(err_page_data) + } else { + InitialState::Present(Some(state_str)) + } +} + +/// Gets the global state injected by the server, if there was any. If there are +/// errors in this, we can return `None` and not worry about it, they'll be +/// handled by the initial state. +pub(crate) fn get_global_state() -> Option<String> { + let val_opt = web_sys::window().unwrap().get("__PERSEUS_GLOBAL_STATE"); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return None, + }; + // The object should only actually contain the string value that was injected + let state_str = match js_obj.as_string() { + Some(state_str) => state_str, + None => return None, + }; + // On the server-side, we encode a `None` value directly (otherwise it will be + // some convoluted stringified JSON) + match state_str.as_str() { + "None" => None, + state_str => Some(state_str.to_string()), + } +} + +/// Gets the translations injected by the server, if there was any. If there are +/// errors in this, we can return `None` and not worry about it, they'll be +/// handled by the initial state. +pub(crate) fn get_translations() -> Option<String> { + let val_opt = web_sys::window().unwrap().get("__PERSEUS_TRANSLATIONS"); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return None, + }; + // The object should only actually contain the string value that was injected + let state_str = match js_obj.as_string() { + Some(state_str) => state_str, + None => return None, + }; + + // With translations, there's no such thing as `None` (even apps without i18n + // have a dummy translator) + Some(state_str) +} diff --git a/packages/perseus/src/router/get_subsequent_view.rs b/packages/perseus/src/router/get_subsequent_view.rs new file mode 100644 index 0000000000..7e83be8a06 --- /dev/null +++ b/packages/perseus/src/router/get_subsequent_view.rs @@ -0,0 +1,198 @@ +use crate::error_pages::ErrorPages; +use crate::errors::*; +use crate::i18n::ClientTranslationsManager; +use crate::page_data::PageData; +use crate::router::{get_global_state, RouteVerdict, RouterLoadState, RouterState}; +use crate::template::{PageProps, Template, TemplateNodeType}; +use crate::utils::checkpoint; +use crate::utils::fetch; +use crate::utils::get_path_prefix_client; +use fmterr::fmt_err; +use std::rc::Rc; +use sycamore::prelude::*; + +pub(crate) struct GetSubsequentViewProps<'a> { + /// The app's reactive scope. + pub cx: Scope<'a>, + /// The path we're rendering for (not the template path, the full path, + /// though parsed a little). + pub path: String, + /// The template to render for. + pub template: Rc<Template<TemplateNodeType>>, + /// Whether or not the router returned an incremental match (if this page + /// exists on a template using incremental generation and it wasn't defined + /// at build time). + pub was_incremental_match: bool, + /// The locale we're rendering in. + pub locale: String, + /// The router state. + pub router_state: RouterState, + /// A *client-side* translations manager to use (this manages caching + /// translations). + pub translations_manager: ClientTranslationsManager, + /// The error pages, for use if something fails. + pub error_pages: Rc<ErrorPages<TemplateNodeType>>, + /// 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>, +} + +/// Gets the view to render on a change of route after the app has already +/// loaded. This involves network requests to determine the state of the page, +/// which is then used to render directly. We don't need to request the HTML, +/// since that would just take longer, and we have everything we need to render +/// it. We also won't be hydrating anything, so there's no point in getting the +/// HTML, it actually slows page transitions down. +/// +/// Note that this will automatically update the router state just before it +/// returns, meaning that any errors that may occur after this function has been +/// called need to reset the router state to be an error. +pub(crate) async fn get_subsequent_view( + GetSubsequentViewProps { + cx, + path, + template, + was_incremental_match, + locale, + mut router_state, + translations_manager, + error_pages, + route_verdict, + }: GetSubsequentViewProps<'_>, +) -> View<TemplateNodeType> { + let path_with_locale = match locale.as_str() { + "xx-XX" => path.clone(), + locale => format!("{}/{}", locale, &path), + }; + // Update the router state + router_state.set_load_state(RouterLoadState::Loading { + template_name: template.get_path(), + path: path_with_locale.clone(), + }); + router_state.set_last_verdict(route_verdict.clone()); + + checkpoint("initial_state_not_present"); + // If we're getting data about the index page, explicitly set it to that + // This can be handled by the Perseus server (and is), but not by static + // exporting + let path = match path.is_empty() { + true => "index".to_string(), + false => path, + }; + // Get the static page data + // TODO Only get the page state here + let asset_url = format!( + "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", + get_path_prefix_client(), + locale, + path, + template.get_path(), + was_incremental_match + ); + // If this doesn't exist, then it's a 404 (we went here by explicit navigation, + // but it may be an unservable ISR page or the like) + let page_data_str = fetch(&asset_url).await; + match &page_data_str { + Ok(page_data_str_opt) => match page_data_str_opt { + Some(page_data_str) => { + // All good, deserialize the page data + let page_data = serde_json::from_str::<PageData>(&page_data_str); + match page_data { + Ok(page_data) => { + // Interpolate the metadata directly into the document's `<head>` + // Get the current head + let head_elem = web_sys::window() + .unwrap() + .document() + .unwrap() + .query_selector("head") + .unwrap() + .unwrap(); + let head_html = head_elem.inner_html(); + // We'll assume that there's already previously interpolated head in + // addition to the hardcoded stuff, but it will be separated by the + // server-injected delimiter comment + // Thus, we replace the stuff after that delimiter comment with the + // new head + let head_parts: Vec<&str> = head_html + .split("<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->") + .collect(); + let new_head = format!( + "{}\n<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->\n{}", + head_parts[0], &page_data.head + ); + head_elem.set_inner_html(&new_head); + + // Now get the translator (this will be cached if the user hasn't switched + // locales) + let translator = translations_manager + .get_translator_for_locale(&locale) + .await; + let translator = match translator { + Ok(translator) => translator, + Err(err) => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + match &err { + // These errors happen because we couldn't get a translator, so they certainly don't get one + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.get_view(cx, url, *status, &fmt_err(&err), None), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.get_view(cx, url, 500, &fmt_err(&err), None), + ClientError::LocaleNotSupported { locale } => return error_pages.get_view(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None), + // No other errors should be returned + _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") + } + } + }; + + let page_props = PageProps { + path: path_with_locale.clone(), + state: page_data.state, + // This will probably be overriden by the already-set version (unless no + // page has used global state yet) + global_state: get_global_state(), + }; + let template_name = template.get_path(); + // Pre-emptively update the router state + checkpoint("page_interactive"); + // Update the router state + router_state.set_load_state(RouterLoadState::Loaded { + template_name, + path: path_with_locale, + }); + // Now return the view that should be rendered + template.render_for_template_client(page_props, cx, translator) + } + // If the page failed to serialize, an exception has occurred + Err(err) => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + panic!("page data couldn't be serialized: '{}'", err) + } + } + } + // No translators ready yet + None => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + error_pages.get_view(cx, &asset_url, 404, "page not found", None) + } + }, + Err(err) => { + router_state.set_load_state(RouterLoadState::ErrorLoaded { + path: path_with_locale.clone(), + }); + match &err { + // No translators ready yet + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => { + error_pages.get_view(cx, url, *status, &fmt_err(&err), None) + } + // No other errors should be returned + _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), + } + } + } +} diff --git a/packages/perseus/src/router/mod.rs b/packages/perseus/src/router/mod.rs index db7d034b20..be0e49c164 100644 --- a/packages/perseus/src/router/mod.rs +++ b/packages/perseus/src/router/mod.rs @@ -1,5 +1,9 @@ #[cfg(target_arch = "wasm32")] mod app_route; +#[cfg(target_arch = "wasm32")] +mod get_initial_view; +#[cfg(target_arch = "wasm32")] +mod get_subsequent_view; mod match_route; mod route_verdict; #[cfg(target_arch = "wasm32")] @@ -15,3 +19,10 @@ pub use route_verdict::{RouteInfo, RouteInfoAtomic, RouteVerdict, RouteVerdictAt #[cfg(target_arch = "wasm32")] pub(crate) use router_component::{perseus_router, PerseusRouterProps}; pub use router_state::{RouterLoadState, RouterState}; + +#[cfg(target_arch = "wasm32")] +pub(crate) use get_initial_view::{ + get_global_state, get_initial_view, GetInitialViewProps, InitialView, +}; +#[cfg(target_arch = "wasm32")] +pub(crate) use get_subsequent_view::{get_subsequent_view, GetSubsequentViewProps}; diff --git a/packages/perseus/src/router/router_component.rs b/packages/perseus/src/router/router_component.rs index 738efca8bc..75630a240d 100644 --- a/packages/perseus/src/router/router_component.rs +++ b/packages/perseus/src/router/router_component.rs @@ -1,20 +1,24 @@ use crate::{ checkpoint, - error_pages::ErrorPageData, i18n::Locales, i18n::{detect_locale, ClientTranslationsManager}, + router::{ + get_initial_view, get_subsequent_view, GetInitialViewProps, GetSubsequentViewProps, + InitialView, RouterLoadState, RouterState, + }, router::{PerseusRoute, RouteInfo, RouteVerdict}, - router::{RouterLoadState, RouterState}, - shell::{app_shell, get_initial_state, InitialState, ShellProps}, template::{RenderCtx, TemplateMap, TemplateNodeType}, - DomNode, ErrorPages, Html, + ErrorPages, }; use std::collections::HashMap; use std::rc::Rc; use sycamore::{ - prelude::{component, create_effect, create_signal, view, NodeRef, ReadSignal, Scope, View}, + prelude::{ + component, create_effect, create_signal, on_mount, view, ReadSignal, Scope, Signal, View, + }, Prop, }; +use sycamore_futures::spawn_local_scoped; use sycamore_router::{HistoryIntegration, RouterBase}; use web_sys::Element; @@ -34,103 +38,75 @@ const ROUTE_ANNOUNCER_STYLES: &str = r#" word-wrap: normal; "#; -/// The properties that `on_route_change` takes. See the shell properties for +/// The properties that `get_view()` takes. See the shell properties for /// the details for most of these. #[derive(Clone)] -struct OnRouteChangeProps<'a, G: Html> { +struct GetViewProps<'a> { cx: Scope<'a>, locales: Rc<Locales>, - container_rx: NodeRef<G>, router_state: RouterState, translations_manager: ClientTranslationsManager, error_pages: Rc<ErrorPages<TemplateNodeType>>, - 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>( +/// Get the view we should be rendering at the moment, based on a route verdict. +/// This should be called on every route change to update the page. This doesn't +/// actually render the view, it just returns it for rendering. Note that this +/// may return error pages. +/// +/// This function is designed for managing subsequent views only, since the +/// router component should be instantiated *after* the initial view +/// has been hydrated. +/// +/// If the page needs to redirect to a particular locale, then this function +/// will imperatively do so. +async fn get_view( verdict: RouteVerdict<TemplateNodeType>, - OnRouteChangeProps { + GetViewProps { cx, locales, - container_rx, router_state, translations_manager, error_pages, - initial_container, - }: OnRouteChangeProps<'_, G>, -) { - sycamore_futures::spawn_local_scoped(cx, 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(ShellProps { - cx, - path: path.clone(), - template: template.clone(), - was_incremental_match: *was_incremental_match, - locale: locale.clone(), - router_state, - translations_manager: translations_manager.clone(), - error_pages: error_pages.clone(), - initial_container: initial_container.unwrap(), - container_rx_elem, - 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(cx, &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(cx, "", 404, "not found", None, &container_rx_elem); - } - } - }; - }); + }: GetViewProps<'_>, +) -> View<TemplateNodeType> { + checkpoint("router_entry"); + match &verdict { + RouteVerdict::Found(RouteInfo { + path, + template, + locale, + was_incremental_match, + }) => { + get_subsequent_view(GetSubsequentViewProps { + cx, + path: path.clone(), + template: template.clone(), + was_incremental_match: *was_incremental_match, + locale: locale.clone(), + router_state, + translations_manager: translations_manager.clone(), + error_pages: error_pages.clone(), + route_verdict: verdict, + }) + .await + } + // For subsequent loads, this should only be possible if the dev forgot `link!()` + RouteVerdict::LocaleDetection(path) => { + let dest = detect_locale(path.clone(), &locales); + // Since this is only for subsequent loads, we know the router is instantiated + // This shouldn't be a replacement navigation, since the user has deliberatley + // navigated here + sycamore_router::navigate(&dest); + View::empty() + } + RouteVerdict::NotFound => { + checkpoint("not_found"); + // TODO Update the router state here (we need a path though...) + // This function only handles subsequent loads, so this is all we have + error_pages.get_view(cx, "", 404, "not found", None) + } + } } /// The properties that the router takes. @@ -157,7 +133,7 @@ pub(crate) struct PerseusRouterProps { /// creates with `create_app_root!` to be provided easily. That given `cx` /// property will be used for all context registration in the app. #[component] -pub(crate) fn perseus_router<G: Html>( +pub(crate) fn perseus_router( cx: Scope, PerseusRouterProps { error_pages, @@ -165,7 +141,53 @@ pub(crate) fn perseus_router<G: Html>( templates, render_cfg, }: PerseusRouterProps, -) -> View<G> { +) -> View<TemplateNodeType> { + let translations_manager = ClientTranslationsManager::new(&locales); + // Get the error pages in an `Rc` so we aren't creating hundreds of them + let error_pages = Rc::new(error_pages); + // Now create an instance of `RenderCtx`, which we'll insert into context and + // use everywhere throughout the app + let render_ctx = RenderCtx::default().set_ctx(cx); + // TODO Replace passing a router state around with getting it out of context + // instead in the shell + let router_state = &render_ctx.router; // We need this for interfacing with the router though + + // Prepare the initial view for hydration (because we have everything we need in + // global window variables, this can be synchronous) + let initial_view = get_initial_view(GetInitialViewProps { + cx, + // Get the path directly, in the same way the Sycamore router's history integration does + path: web_sys::window().unwrap().location().pathname().unwrap(), + router_state: router_state.clone(), + translations_manager: &translations_manager, + error_pages: &error_pages, + locales: &locales, + templates: &templates, + render_cfg: &render_cfg, + }); + let initial_view = match initial_view { + InitialView::View(initial_view) => initial_view, + // if we need to redirect, then we'll create a fake view that will just execute that code + // (which we can guarantee to run after the router is ready) + InitialView::Redirect(dest) => { + let dest = dest.clone(); + on_mount(cx, move || { + sycamore_router::navigate_replace(&dest); + }); + // Note that using an empty view doesn't lead to any layout shift, since + // redirects have nothing rendered on the server-side (so this is actually + // correct hydration) + View::empty() + } + }; + // Define a `Signal` for the view we're going to be currently rendering, which + // will contain the current page, or some kind of error message + let curr_view: &Signal<View<TemplateNodeType>> = create_signal(cx, initial_view); + // This allows us to not run the subsequent load code on the initial load (we + // need a separate one for the reload commander) + let is_initial = create_signal(cx, true); + let is_initial_reload_commander = create_signal(cx, true); + // Create a `Route` to pass through Sycamore with the information we need let route = PerseusRoute { verdict: RouteVerdict::NotFound, @@ -174,33 +196,8 @@ pub(crate) fn perseus_router<G: Html>( locales: locales.clone(), }; - // Get the root that the server will have injected initial load content into - // This will be moved into a reactive `<div>` by the app shell - // This is an `Option<Element>` until we know we aren't doing locale detection - // (in which case it wouldn't exist) - let initial_container = web_sys::window() - .unwrap() - .document() - .unwrap() - .query_selector("#__perseus_content_initial") - .unwrap(); - // And create a node reference that we can use as a handle to the reactive - // verison - let container_rx = NodeRef::new(); - - let translations_manager = ClientTranslationsManager::new(&locales); // Now that we've used the reference, put the locales in an `Rc` let locales = Rc::new(locales); - // Get the error pages in an `Rc` so we aren't creating hundreds of them - let error_pages = Rc::new(error_pages); - - // Now create an instance of `RenderCtx`, which we'll insert into context and - // use everywhere throughout the app - let render_ctx = RenderCtx::default().set_ctx(cx); - - // TODO Replace passing a router state around with getting it out of context - // instead in the shell - let router_state = &render_ctx.router; // We need this for interfacing with the router though // Create a derived state for the route announcement // We do this with an effect because we only want to update in some cases (when @@ -261,35 +258,45 @@ pub(crate) fn perseus_router<G: Html>( // 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 { + let get_view_props = GetViewProps { cx, locales, - container_rx: container_rx.clone(), router_state: router_state.clone(), translations_manager, error_pages, - initial_container, }; // Listen for changes to the reload commander and reload as appropriate - let orcp_clone = on_route_change_props.clone(); + let gvp = get_view_props.clone(); create_effect(cx, move || { router_state.reload_commander.track(); - // Get the route verdict and re-run the function we use on route changes - // This has to be untracked, otherwise we get an infinite loop that will - // actually break client browsers (I had to manually kill Firefox...) - // TODO Investigate how the heck this actually caused an infinite loop... - let verdict = router_state.get_last_verdict(); - let verdict = match &verdict { - Some(verdict) => verdict, - // If the first page hasn't loaded yet, terminate now - None => return, - }; - on_route_change(verdict.clone(), orcp_clone.clone()); + // Using a tracker of the initial state separate to the main one is fine, + // because this effect is guaranteed to fire on page load (they'll both be set) + if *is_initial_reload_commander.get_untracked() { + is_initial_reload_commander.set(false); + } else { + // Get the route verdict and re-run the function we use on route changes + // This has to be untracked, otherwise we get an infinite loop that will + // actually break client browsers (I had to manually kill Firefox...) + // TODO Investigate how the heck this actually caused an infinite loop... + let verdict = router_state.get_last_verdict(); + let verdict = match verdict { + Some(verdict) => verdict, + // If the first page hasn't loaded yet, terminate now + None => return, + }; + // Because of the way futures work, a double clone is unfortunately necessary + // for now + let gvp = gvp.clone(); + spawn_local_scoped(cx, async move { + let new_view = get_view(verdict.clone(), gvp).await; + curr_view.set(new_view); + }); + } }); // This section handles live reloading and HSR freezing - // We used to haev an indicator shared to the macros, but that's no longer used + // We used to have an indicator shared to the macros, but that's no longer used #[cfg(all(feature = "live-reload", debug_assertions))] { use crate::state::Freeze; @@ -337,27 +344,56 @@ pub(crate) fn perseus_router<G: Html>( }); }; + // Append the route announcer to the end of the document body + let document = web_sys::window().unwrap().document().unwrap(); + let announcer = document.create_element("p").unwrap(); + announcer.set_attribute("aria-live", "assertive").unwrap(); + announcer.set_attribute("role", "alert").unwrap(); + announcer + .set_attribute("style", ROUTE_ANNOUNCER_STYLES) + .unwrap(); + announcer.set_id("__perseus_route_announcer"); + let body_elem: Element = document.body().unwrap().into(); + body_elem + .append_with_node_1(&announcer.clone().into()) + .unwrap(); + // Update the announcer's text whenever the `route_announcement` changes + create_effect(cx, move || { + let ra = route_announcement.get(); + announcer.set_inner_html(&ra); + }); + view! { cx, // This is a lower-level version of `Router` that lets us provide a `Route` with the data we want RouterBase { integration: HistoryIntegration::new(), route, - view: move |cx, route: &ReadSignal<PerseusRoute<TemplateNodeType>>| { - // 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) + view: move |cx, route: &ReadSignal<PerseusRoute>| { + // Do this on every update to the route, except the first time, when we'll use the initial load create_effect(cx, move || { - let route = route.get(); - let verdict = route.get_verdict(); - on_route_change(verdict.clone(), on_route_change_props.clone()); + route.track(); + + if *is_initial.get_untracked() { + is_initial.set(false); + } else { + let gvp = get_view_props.clone(); + spawn_local_scoped(cx, async move { + let route = route.get(); + let verdict = route.get_verdict(); + let new_view = get_view(verdict.clone(), gvp).await; + curr_view.set(new_view); + }); + } }); // 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 + view! { cx, + // BUG (Sycamore): We can't remove this `div` yet... div { - div(id="__perseus_content_rx", class="__perseus_content", ref=container_rx) {} - p(id = "__perseus_route_announcer", aria_live = "assertive", role = "alert", style = ROUTE_ANNOUNCER_STYLES) { (route_announcement.get()) } + (*curr_view.get()) } } } diff --git a/packages/perseus/src/router/router_state.rs b/packages/perseus/src/router/router_state.rs index 34a4022bf2..180af2e558 100644 --- a/packages/perseus/src/router/router_state.rs +++ b/packages/perseus/src/router/router_state.rs @@ -86,6 +86,12 @@ pub enum RouterLoadState { /// we're using i18n). path: String, }, + /// An error page has been loaded. + ErrorLoaded { + /// The full path to the page we intended to load, on which the error + /// occurred (including the locale, if we're using i18n). + path: String, + }, /// A new page is being loaded, and will soon replace whatever is currently /// loaded. The name of the new template is attached. Loading { diff --git a/packages/perseus/src/server/build_error_page.rs b/packages/perseus/src/server/build_error_page.rs index d308fb1ed9..147ddff669 100644 --- a/packages/perseus/src/server/build_error_page.rs +++ b/packages/perseus/src/server/build_error_page.rs @@ -10,6 +10,10 @@ use std::rc::Rc; /// the correct error page. Note that this is only for use in initial loads /// (other systems handle errors in subsequent loads, and the app shell /// exists then so the server doesn't have to do nearly as much work). +/// +/// This doesn't inject translations of any sort, deliberately, since +/// we can't ensure that they would even exist --- this is used for all +/// types of server-side errors. pub fn build_error_page( url: &str, status: u16, diff --git a/packages/perseus/src/server/html_shell.rs b/packages/perseus/src/server/html_shell.rs index 5174f897d4..0ffc165fb9 100644 --- a/packages/perseus/src/server/html_shell.rs +++ b/packages/perseus/src/server/html_shell.rs @@ -142,8 +142,18 @@ impl HtmlShell { } } - /// Interpolates page data and global state into the shell. - pub fn page_data(mut self, page_data: &PageData, global_state: &Option<String>) -> Self { + /// Interpolates page data, global state, and translations into the shell. + /// + /// The translations provided should be the source string from which a + /// translator can be derived on the client-side. These are provided in + /// a window variable to avoid page interactivity requiring a network + /// request to get them. + pub fn page_data( + mut self, + page_data: &PageData, + global_state: &Option<String>, + translations: &str, + ) -> Self { // Interpolate a global variable of the state so the app shell doesn't have to // make any more trips The app shell will unset this after usage so it // doesn't contaminate later non-initial loads Error pages (above) will @@ -158,6 +168,7 @@ impl HtmlShell { } else { "None".to_string() }; + let translations = escape_page_data(translations); // We put this at the very end of the head (after the delimiter comment) because // it doesn't matter if it's expunged on subsequent loads @@ -168,6 +179,13 @@ impl HtmlShell { // and not need this after the initial load) let global_state = format!("window.__PERSEUS_GLOBAL_STATE = `{}`;", global_state); self.scripts_before_boundary.push(global_state); + // We can put the translations after the boundary, because we'll only need them + // on the first page, and then they'll be automatically cached + // + // Note that we don't need to interpolate the locale, since that's trivially + // known from the URL + let translations = format!("window.__PERSEUS_TRANSLATIONS = `{}`;", translations); + self.scripts_after_boundary.push(translations); // Interpolate the document `<head>` (this should of course be removed between // page loads) self.head_after_boundary.push((&page_data.head).into()); @@ -202,7 +220,9 @@ impl HtmlShell { ); // This will be used if JS is enabled, but Wasm is disabled or not supported - // (it's then the site's responsibility to show a further message) Wasm support detector courtesy https://stackoverflow.com/a/47880734 + // (it's then the site's responsibility to show a further message) + // + // Wasm support detector courtesy https://stackoverflow.com/a/47880734 let js_redirect = format!( r#" function wasmSupported() {{ @@ -247,6 +267,11 @@ impl HtmlShell { } /// Interpolates page error data into the shell in the event of a failure. + /// + /// Importantly, this makes no assumptions about the availability of + /// translations, so error pages rendered from here will not be + /// internationalized. + // TODO Provide translations where we can at least? pub fn error_page(mut self, error_page_data: &ErrorPageData, error_html: &str) -> Self { let error = serde_json::to_string(error_page_data).unwrap(); let state_var = format!( @@ -298,7 +323,7 @@ impl fmt::Display for HtmlShell { let html_replacement = format!( // We give the content a specific ID so that it can be deleted if an error page needs // to be rendered on the client-side - r#"{}<div id="__perseus_content_initial" class="__perseus_content">{}</div>"#, + "{}{}", &html_to_replace_double, self.content, ); // Now interpolate that HTML into the HTML shell diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs deleted file mode 100644 index 35dfb2bdbb..0000000000 --- a/packages/perseus/src/shell.rs +++ /dev/null @@ -1,568 +0,0 @@ -use crate::error_pages::ErrorPageData; -use crate::errors::*; -use crate::i18n::ClientTranslationsManager; -use crate::page_data::PageData; -use crate::router::{RouteVerdict, RouterLoadState, RouterState}; -use crate::template::{PageProps, Template, TemplateNodeType}; -use crate::utils::get_path_prefix_client; -use crate::ErrorPages; -use fmterr::fmt_err; -use std::collections::HashMap; -use std::rc::Rc; -use sycamore::prelude::*; -use sycamore::rt::Reflect; // We can piggyback off Sycamore to avoid bringing in `js_sys` -use wasm_bindgen::{JsCast, JsValue}; -use wasm_bindgen_futures::JsFuture; -use web_sys::{Element, Request, RequestInit, RequestMode, Response}; - -/// Fetches the given resource. This should NOT be used by end users, but it's -/// required by the CLI. -pub(crate) async fn fetch(url: &str) -> Result<Option<String>, ClientError> { - let js_err_handler = |err: JsValue| ClientError::Js(format!("{:?}", err)); - let mut opts = RequestInit::new(); - opts.method("GET").mode(RequestMode::Cors); - - let request = Request::new_with_str_and_init(url, &opts).map_err(js_err_handler)?; - - let window = web_sys::window().unwrap(); - // Get the response as a future and await it - let res_value = JsFuture::from(window.fetch_with_request(&request)) - .await - .map_err(js_err_handler)?; - // Turn that into a proper response object - let res: Response = res_value.dyn_into().unwrap(); - // If the status is 404, we should return that the request worked but no file - // existed - if res.status() == 404 { - return Ok(None); - } - // Get the body thereof - let body_promise = res.text().map_err(js_err_handler)?; - let body = JsFuture::from(body_promise).await.map_err(js_err_handler)?; - - // Convert that into a string (this will be `None` if it wasn't a string in the - // JS) - let body_str = body.as_string(); - let body_str = match body_str { - Some(body_str) => body_str, - None => { - return Err(FetchError::NotString { - url: url.to_string(), - } - .into()) - } - }; - // Handle non-200 error codes - if res.status() == 200 { - Ok(Some(body_str)) - } else { - Err(FetchError::NotOk { - url: url.to_string(), - status: res.status(), - err: body_str, - } - .into()) - } -} - -/// Gets the render configuration from the JS global variable -/// `__PERSEUS_RENDER_CFG`, which should be inlined by the server. This will -/// return `None` on any error (not found, serialization failed, etc.), which -/// should reasonably lead to a `panic!` in the caller. -pub(crate) fn get_render_cfg() -> Option<HashMap<String, String>> { - let val_opt = web_sys::window().unwrap().get("__PERSEUS_RENDER_CFG"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return None, - }; - // The object should only actually contain the string value that was injected - let cfg_str = match js_obj.as_string() { - Some(cfg_str) => cfg_str, - None => return None, - }; - let render_cfg = match serde_json::from_str::<HashMap<String, String>>(&cfg_str) { - Ok(render_cfg) => render_cfg, - Err(_) => return None, - }; - - Some(render_cfg) -} - -/// Gets the initial state injected by the server, if there was any. This is -/// used to differentiate initial loads from subsequent ones, which have -/// different log chains to prevent double-trips (a common SPA problem). -pub(crate) fn get_initial_state() -> InitialState { - let val_opt = web_sys::window().unwrap().get("__PERSEUS_INITIAL_STATE"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return InitialState::NotPresent, - }; - // The object should only actually contain the string value that was injected - let state_str = match js_obj.as_string() { - Some(state_str) => state_str, - None => return InitialState::NotPresent, - }; - // On the server-side, we encode a `None` value directly (otherwise it will be - // some convoluted stringified JSON) - if state_str == "None" { - InitialState::Present(None) - } else if state_str.starts_with("error-") { - // We strip the prefix and escape any tab/newline control characters (inserted - // by `fmterr`) Any others are user-inserted, and this is documented - let err_page_data_str = state_str - .strip_prefix("error-") - .unwrap() - .replace('\n', "\\n") - .replace('\t', "\\t"); - // There will be error page data encoded after `error-` - let err_page_data = match serde_json::from_str::<ErrorPageData>(&err_page_data_str) { - Ok(render_cfg) => render_cfg, - // If there's a serialization error, we'll create a whole new error (500) - Err(err) => ErrorPageData { - url: "[current]".to_string(), - status: 500, - err: format!("couldn't serialize error from server: '{}'", err), - }, - }; - InitialState::Error(err_page_data) - } else { - InitialState::Present(Some(state_str)) - } -} - -/// Gets the global state injected by the server, if there was any. If there are -/// errors in this, we can return `None` and not worry about it, they'll be -/// handled by the initial state. -pub(crate) fn get_global_state() -> Option<String> { - let val_opt = web_sys::window().unwrap().get("__PERSEUS_GLOBAL_STATE"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return None, - }; - // The object should only actually contain the string value that was injected - let state_str = match js_obj.as_string() { - Some(state_str) => state_str, - None => return None, - }; - // On the server-side, we encode a `None` value directly (otherwise it will be - // some convoluted stringified JSON) - match state_str.as_str() { - "None" => None, - state_str => Some(state_str.to_string()), - } -} - -/// Marks a checkpoint in the code and alerts any tests that it's been reached -/// by creating an element that represents it. The preferred solution would be -/// emitting a DOM event, but the WebDriver specification currently doesn't -/// support waiting on those (go figure). This will only create a custom element -/// if the `__PERSEUS_TESTING` JS global variable is set to `true`. -/// -/// This adds a `<div id="__perseus_checkpoint-<event-name>" />` to the `<div -/// id="__perseus_checkpoints"></div>` element, creating the latter if it -/// doesn't exist. Each checkpoint must have a unique name, and if the same -/// checkpoint is executed twice, it'll be added with a `-<number>` after it, -/// starting from `0`. In this way, we have a functional checkpoints queue for -/// signalling to test code! Note that the checkpoint queue is NOT cleared on -/// subsequent loads. -/// -/// Note: this is not just for internal usage, it's highly recommended that you -/// use this for your own checkpoints as well! Just make sure your tests don't -/// conflict with any internal Perseus checkpoint names (preferably prefix yours -/// with `custom-` or the like, as Perseus' checkpoints may change at any time, -/// but won't ever use that namespace). -/// -/// WARNING: your checkpoint names must not include hyphens! This will result in -/// a `panic!`. -pub fn checkpoint(name: &str) { - if name.contains('-') { - panic!("checkpoint must not contain hyphens, use underscores instead (hyphens are used as an internal delimiter)"); - } - - let val_opt = web_sys::window().unwrap().get("__PERSEUS_TESTING"); - let js_obj = match val_opt { - Some(js_obj) => js_obj, - None => return, - }; - // The object should only actually contain the string value that was injected - let is_testing = match js_obj.as_bool() { - Some(cfg_str) => cfg_str, - None => return, - }; - if !is_testing { - return; - } - - // If we're here, we're testing - // We dispatch a console warning to reduce the likelihood of literal 'testing in - // prod' - crate::web_log!("Perseus is in testing mode. If you're an end-user and seeing this message, please report this as a bug to the website owners!"); - // Create a custom element that can be waited for by the WebDriver - // This will be removed by the next checkpoint - let document = web_sys::window().unwrap().document().unwrap(); - let container_opt = document.query_selector("#__perseus_checkpoints").unwrap(); - let container = match container_opt { - Some(container_i) => container_i, - None => { - // If the container doesn't exist yet, create it - let container = document.create_element("div").unwrap(); - container.set_id("__perseus_checkpoints"); - document - .query_selector("body") - .unwrap() - .unwrap() - .append_with_node_1(&container) - .unwrap(); - container - } - }; - - // Get the number of checkpoints that already exist with the same ID - // We prevent having to worry about checkpoints whose names are subsets of - // others by using the hyphen as a delimiter - let num_checkpoints = document - .query_selector_all(&format!("[id^=__perseus_checkpoint-{}-]", name)) - .unwrap() - .length(); - // Append the new checkpoint - let checkpoint = document.create_element("div").unwrap(); - checkpoint.set_id(&format!( - "__perseus_checkpoint-{}-{}", - name, num_checkpoints - )); - container.append_with_node_1(&checkpoint).unwrap(); -} - -/// A representation of whether or not the initial state was present. If it was, -/// it could be `None` (some templates take no state), and if not, then this -/// isn't an initial load, and we need to request the page from the server. It -/// could also be an error that the server has rendered. -#[derive(Debug)] -pub(crate) enum InitialState { - /// A non-error initial state has been injected. - Present(Option<String>), - /// An initial state ahs been injected that indicates an error. - Error(ErrorPageData), - /// No initial state has been injected (or if it has, it's been deliberately - /// unset). - NotPresent, -} - -/// Properties for the app shell. These should be constructed literally when -/// working with the app shell. -// #[derive(Debug)] -pub(crate) struct ShellProps<'a> { - /// The app's reactive scope. - pub cx: Scope<'a>, - /// The path we're rendering for (not the template path, the full path, - /// though parsed a little). - pub path: String, - /// The template to render for. - pub template: Rc<Template<TemplateNodeType>>, - /// Whether or not the router returned an incremental match (if this page - /// exists on a template using incremental generation and it wasn't defined - /// at build time). - pub was_incremental_match: bool, - /// The locale we're rendering in. - pub locale: String, - /// The router state. - pub router_state: RouterState, - /// A *client-side* translations manager to use (this manages caching - /// translations). - pub translations_manager: ClientTranslationsManager, - /// The error pages, for use if something fails. - pub error_pages: Rc<ErrorPages<TemplateNodeType>>, - /// The container responsible for the initial render from the server - /// (non-interactive, this may need to be wiped). - pub initial_container: Element, - /// The container for reactive content. - pub container_rx_elem: Element, - /// 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 -/// broader template). Asynchronous Wasm is handled here, because only a few -/// cases need it. -// TODO handle exceptions higher up -pub(crate) async fn app_shell( - ShellProps { - cx, - path, - template, - was_incremental_match, - locale, - mut router_state, - translations_manager, - error_pages, - initial_container, - container_rx_elem, - route_verdict, - }: ShellProps<'_>, -) { - checkpoint("app_shell_entry"); - let path_with_locale = match locale.as_str() { - "xx-XX" => path.clone(), - locale => format!("{}/{}", locale, &path), - }; - // Update the router state - router_state.set_load_state(RouterLoadState::Loading { - template_name: template.get_path(), - path: path_with_locale.clone(), - }); - router_state.set_last_verdict(route_verdict.clone()); - // 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(); - // Check if this was an initial load and we already have the state - let initial_state = get_initial_state(); - match initial_state { - // If we do have an initial state, then we have everything we need for immediate hydration - // (no double trips) The state is here, and the HTML has already been injected for - // us (including head metadata) - InitialState::Present(state) => { - checkpoint("initial_state_present"); - // Unset the initial state variable so we perform subsequent renders correctly - // This monstrosity is needed until `web-sys` adds a `.set()` method on `Window` - // We don't do this for the global state because it should hang around - // uninitialized until a template wants it (if we remove it before then, we're - // stuffed) - Reflect::set( - &JsValue::from(web_sys::window().unwrap()), - &JsValue::from("__PERSEUS_INITIAL_STATE"), - &JsValue::undefined(), - ) - .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) - 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(); - checkpoint("page_visible"); - - // Now that the user can see something, we can get the translator - let translator = translations_manager - .get_translator_for_locale(&locale) - .await; - let translator = match translator { - Ok(translator) => translator, - Err(err) => { - // Directly eliminate the HTML sent in from the server before we render an error - // page - container_rx_elem.set_inner_html(""); - match &err { - // These errors happen because we couldn't get a translator, so they certainly don't get one - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(cx, url, *status, &fmt_err(&err), None, &container_rx_elem), - ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(cx, url, 500, &fmt_err(&err), None, &container_rx_elem), - ClientError::LocaleNotSupported { .. } => return error_pages.render_page(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None, &container_rx_elem), - // No other errors should be returned - _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") - } - } - }; - - let path = template.get_path(); - // Hydrate that static code using the acquired state - // BUG (Sycamore): this will double-render if the component is just text (no - // nodes) - let page_props = PageProps { - path: path_with_locale.clone(), - state, - global_state, - }; - #[cfg(not(feature = "hydrate"))] - { - // If we aren't hydrating, we'll have to delete everything and re-render - container_rx_elem.set_inner_html(""); - sycamore::render_to( - move |_| template.render_for_template_client(page_props, cx, translator), - &container_rx_elem, - ); - } - #[cfg(feature = "hydrate")] - sycamore::hydrate_to( - // This function provides translator context as needed - |_| template.render_for_template_client(page_props, cx, translator), - &container_rx_elem, - ); - checkpoint("page_interactive"); - // Update the router state - router_state.set_load_state(RouterLoadState::Loaded { - template_name: path, - path: path_with_locale, - }); - } - // If we have no initial state, we should proceed as usual, fetching the content and state - // from the server - InitialState::NotPresent => { - checkpoint("initial_state_not_present"); - // If we're getting data about the index page, explicitly set it to that - // This can be handled by the Perseus server (and is), but not by static - // exporting - let path = match path.is_empty() { - true => "index".to_string(), - false => path, - }; - // Get the static page data - let asset_url = format!( - "{}/.perseus/page/{}/{}.json?template_name={}&was_incremental_match={}", - get_path_prefix_client(), - locale, - path, - template.get_path(), - was_incremental_match - ); - // If this doesn't exist, then it's a 404 (we went here by explicit navigation, - // but it may be an unservable ISR page or the like) - let page_data_str = fetch(&asset_url).await; - match page_data_str { - Ok(page_data_str) => match page_data_str { - Some(page_data_str) => { - // All good, deserialize the page data - let page_data = serde_json::from_str::<PageData>(&page_data_str); - match page_data { - Ok(page_data) => { - // We have the page data ready, render everything - // Interpolate the HTML directly into the document (we'll hydrate it - // later) - container_rx_elem.set_inner_html(&page_data.content); - // Interpolate the metadata directly into the document's `<head>` - // Get the current head - let head_elem = web_sys::window() - .unwrap() - .document() - .unwrap() - .query_selector("head") - .unwrap() - .unwrap(); - let head_html = head_elem.inner_html(); - // We'll assume that there's already previously interpolated head in - // addition to the hardcoded stuff, but it will be separated by the - // server-injected delimiter comment - // Thus, we replace the stuff after that delimiter comment with the - // new head - let head_parts: Vec<&str> = head_html - .split("<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->") - .collect(); - let new_head = format!( - "{}\n<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->\n{}", - head_parts[0], &page_data.head - ); - head_elem.set_inner_html(&new_head); - checkpoint("page_visible"); - - // Now that the user can see something, we can get the translator - let translator = translations_manager - .get_translator_for_locale(&locale) - .await; - let translator = match translator { - Ok(translator) => translator, - Err(err) => match &err { - // These errors happen because we couldn't get a translator, so they certainly don't get one - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(cx, url, *status, &fmt_err(&err), None, &container_rx_elem), - ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(cx, url, 500, &fmt_err(&err), None, &container_rx_elem), - ClientError::LocaleNotSupported { locale } => return error_pages.render_page(cx, &format!("/{}/...", locale), 404, &fmt_err(&err), None, &container_rx_elem), - // No other errors should be returned - _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") - } - }; - - // Hydrate that static code using the acquired state - // BUG (Sycamore): this will double-render if the component is just - // text (no nodes) - let page_props = PageProps { - path: path_with_locale.clone(), - state: page_data.state, - global_state, - }; - let template_name = template.get_path(); - #[cfg(not(feature = "hydrate"))] - { - // If we aren't hydrating, we'll have to delete everything and - // re-render - container_rx_elem.set_inner_html(""); - sycamore::render_to( - move |_| { - template.render_for_template_client( - page_props, cx, translator, - ) - }, - &container_rx_elem, - ); - } - #[cfg(feature = "hydrate")] - sycamore::hydrate_to( - // This function provides translator context as needed - move |_| { - template - .render_for_template_client(page_props, cx, translator) - }, - &container_rx_elem, - ); - checkpoint("page_interactive"); - // Update the router state - router_state.set_load_state(RouterLoadState::Loaded { - template_name, - path: path_with_locale, - }); - } - // If the page failed to serialize, an exception has occurred - Err(err) => panic!("page data couldn't be serialized: '{}'", err), - }; - } - // No translators ready yet - None => error_pages.render_page( - cx, - &asset_url, - 404, - "page not found", - None, - &container_rx_elem, - ), - }, - Err(err) => match &err { - // No translators ready yet - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => error_pages - .render_page(cx, url, *status, &fmt_err(&err), None, &container_rx_elem), - // No other errors should be returned - _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), - }, - }; - } - // Nothing should be done if an error was sent down - InitialState::Error(ErrorPageData { url, status, err }) => { - checkpoint("initial_state_error"); - // 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 currently static error page - // Right now, we don't provide translators to any error pages that have come - // from the server We render this rather than hydrating because - // otherwise we'd need a `HydrateNode` at the plugins level, which is way too - // inefficient - #[cfg(not(feature = "hydrate"))] - container_rx_elem.set_inner_html(""); - error_pages.render_page(cx, &url, status, &err, None, &container_rx_elem); - } - }; -} diff --git a/packages/perseus/src/state/global_state.rs b/packages/perseus/src/state/global_state.rs index 53945427e8..2bfee21f76 100644 --- a/packages/perseus/src/state/global_state.rs +++ b/packages/perseus/src/state/global_state.rs @@ -1,4 +1,5 @@ use super::rx_state::AnyFreeze; +#[cfg(not(target_arch = "wasm32"))] // To suppress warnings use crate::errors::*; use crate::make_async_trait; use crate::template::RenderFnResult; @@ -10,6 +11,7 @@ use std::rc::Rc; make_async_trait!(GlobalStateCreatorFnType, RenderFnResult<String>); /// The type of functions that generate global state. These will generate a /// `String` for their custom global state type. +#[cfg(not(target_arch = "wasm32"))] pub type GlobalStateCreatorFn = Box<dyn GlobalStateCreatorFnType + Send + Sync>; /// A creator for global state. This stores user-provided functions that will be diff --git a/packages/perseus/src/template/render_ctx.rs b/packages/perseus/src/template/render_ctx.rs index 02b3f696c9..3d1dd099df 100644 --- a/packages/perseus/src/template/render_ctx.rs +++ b/packages/perseus/src/template/render_ctx.rs @@ -66,6 +66,7 @@ impl Freeze for RenderCtx { route: match &*self.router.get_load_state_rc().get_untracked() { RouterLoadState::Loaded { path, .. } => path, RouterLoadState::Loading { path, .. } => path, + RouterLoadState::ErrorLoaded { path } => path, // If we encounter this during re-hydration, we won't try to set the URL in the // browser RouterLoadState::Server => "SERVER", @@ -120,10 +121,13 @@ impl RenderCtx { let curr_route = match &*self.router.get_load_state_rc().get_untracked() { RouterLoadState::Loaded { path, .. } => path.to_string(), RouterLoadState::Loading { path, .. } => path.to_string(), + RouterLoadState::ErrorLoaded { 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 handle the possibility that the page tried to reload before it had been + // made interactive here (we'll just reload wherever we are) + if curr_route == route || route == "SERVER" { // 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 diff --git a/packages/perseus/src/utils/checkpoint.rs b/packages/perseus/src/utils/checkpoint.rs new file mode 100644 index 0000000000..b46adda73d --- /dev/null +++ b/packages/perseus/src/utils/checkpoint.rs @@ -0,0 +1,80 @@ +/// Marks a checkpoint in the code and alerts any tests that it's been reached +/// by creating an element that represents it. The preferred solution would be +/// emitting a DOM event, but the WebDriver specification currently doesn't +/// support waiting on those (go figure). This will only create a custom element +/// if the `__PERSEUS_TESTING` JS global variable is set to `true`. +/// +/// This adds a `<div id="__perseus_checkpoint-<event-name>" />` to the `<div +/// id="__perseus_checkpoints"></div>` element, creating the latter if it +/// doesn't exist. Each checkpoint must have a unique name, and if the same +/// checkpoint is executed twice, it'll be added with a `-<number>` after it, +/// starting from `0`. In this way, we have a functional checkpoints queue for +/// signalling to test code! Note that the checkpoint queue is NOT cleared on +/// subsequent loads. +/// +/// Note: this is not just for internal usage, it's highly recommended that you +/// use this for your own checkpoints as well! Just make sure your tests don't +/// conflict with any internal Perseus checkpoint names (preferably prefix yours +/// with `custom-` or the like, as Perseus' checkpoints may change at any time, +/// but won't ever use that namespace). +/// +/// WARNING: your checkpoint names must not include hyphens! This will result in +/// a `panic!`. +pub fn checkpoint(name: &str) { + if name.contains('-') { + panic!("checkpoint must not contain hyphens, use underscores instead (hyphens are used as an internal delimiter)"); + } + + let val_opt = web_sys::window().unwrap().get("__PERSEUS_TESTING"); + let js_obj = match val_opt { + Some(js_obj) => js_obj, + None => return, + }; + // The object should only actually contain the string value that was injected + let is_testing = match js_obj.as_bool() { + Some(cfg_str) => cfg_str, + None => return, + }; + if !is_testing { + return; + } + + // If we're here, we're testing + // We dispatch a console warning to reduce the likelihood of literal 'testing in + // prod' + crate::web_log!("Perseus is in testing mode. If you're an end-user and seeing this message, please report this as a bug to the website owners!"); + // Create a custom element that can be waited for by the WebDriver + // This will be removed by the next checkpoint + let document = web_sys::window().unwrap().document().unwrap(); + let container_opt = document.query_selector("#__perseus_checkpoints").unwrap(); + let container = match container_opt { + Some(container_i) => container_i, + None => { + // If the container doesn't exist yet, create it + let container = document.create_element("div").unwrap(); + container.set_id("__perseus_checkpoints"); + document + .query_selector("body") + .unwrap() + .unwrap() + .append_with_node_1(&container) + .unwrap(); + container + } + }; + + // Get the number of checkpoints that already exist with the same ID + // We prevent having to worry about checkpoints whose names are subsets of + // others by using the hyphen as a delimiter + let num_checkpoints = document + .query_selector_all(&format!("[id^=__perseus_checkpoint-{}-]", name)) + .unwrap() + .length(); + // Append the new checkpoint + let checkpoint = document.create_element("div").unwrap(); + checkpoint.set_id(&format!( + "__perseus_checkpoint-{}-{}", + name, num_checkpoints + )); + container.append_with_node_1(&checkpoint).unwrap(); +} diff --git a/packages/perseus/src/utils/context.rs b/packages/perseus/src/utils/context.rs index d597e0cbf2..6ab63b94f0 100644 --- a/packages/perseus/src/utils/context.rs +++ b/packages/perseus/src/utils/context.rs @@ -1,8 +1,16 @@ -use sycamore::prelude::{create_signal, use_context_or_else_ref, Scope, Signal}; +use sycamore::prelude::{ + create_signal, provide_context_ref, try_use_context, use_context, Scope, Signal, +}; /// Adds the given value to the given reactive scope inside a `Signal`, /// replacing a value of that type if one is already present. This returns a /// reference to the `Signal` inserted. pub(crate) fn provide_context_signal_replace<T: 'static>(cx: Scope, val: T) -> &Signal<T> { - use_context_or_else_ref(cx, || create_signal(cx, val)) + if let Some(ctx) = try_use_context::<Signal<T>>(cx) { + ctx.set(val); + } else { + provide_context_ref(cx, create_signal(cx, val)); + } + + use_context(cx) } diff --git a/packages/perseus/src/utils/fetch.rs b/packages/perseus/src/utils/fetch.rs new file mode 100644 index 0000000000..57d5c9fd43 --- /dev/null +++ b/packages/perseus/src/utils/fetch.rs @@ -0,0 +1,54 @@ +use crate::errors::*; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Request, RequestInit, RequestMode, Response}; + +/// Fetches the given resource. This should NOT be used by end users, but it's +/// required by the CLI. +pub(crate) async fn fetch(url: &str) -> Result<Option<String>, ClientError> { + let js_err_handler = |err: JsValue| ClientError::Js(format!("{:?}", err)); + let mut opts = RequestInit::new(); + opts.method("GET").mode(RequestMode::Cors); + + let request = Request::new_with_str_and_init(url, &opts).map_err(js_err_handler)?; + + let window = web_sys::window().unwrap(); + // Get the response as a future and await it + let res_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(js_err_handler)?; + // Turn that into a proper response object + let res: Response = res_value.dyn_into().unwrap(); + // If the status is 404, we should return that the request worked but no file + // existed + if res.status() == 404 { + return Ok(None); + } + // Get the body thereof + let body_promise = res.text().map_err(js_err_handler)?; + let body = JsFuture::from(body_promise).await.map_err(js_err_handler)?; + + // Convert that into a string (this will be `None` if it wasn't a string in the + // JS) + let body_str = body.as_string(); + let body_str = match body_str { + Some(body_str) => body_str, + None => { + return Err(FetchError::NotString { + url: url.to_string(), + } + .into()) + } + }; + // Handle non-200 error codes + if res.status() == 200 { + Ok(Some(body_str)) + } else { + Err(FetchError::NotOk { + url: url.to_string(), + status: res.status(), + err: body_str, + } + .into()) + } +} diff --git a/packages/perseus/src/utils/mod.rs b/packages/perseus/src/utils/mod.rs index 4727c2318b..94d752ad36 100644 --- a/packages/perseus/src/utils/mod.rs +++ b/packages/perseus/src/utils/mod.rs @@ -1,9 +1,13 @@ mod async_fn_trait; #[cfg(not(target_arch = "wasm32"))] mod cache_res; +#[cfg(target_arch = "wasm32")] +mod checkpoint; mod context; #[cfg(not(target_arch = "wasm32"))] mod decode_time_str; +#[cfg(target_arch = "wasm32")] +mod fetch; mod log; mod path_prefix; mod test; @@ -11,7 +15,11 @@ mod test; pub(crate) use async_fn_trait::AsyncFnReturn; #[cfg(not(target_arch = "wasm32"))] pub use cache_res::{cache_fallible_res, cache_res}; +#[cfg(target_arch = "wasm32")] +pub use checkpoint::checkpoint; pub(crate) use context::provide_context_signal_replace; #[cfg(not(target_arch = "wasm32"))] pub use decode_time_str::{ComputedDuration, Duration, InvalidDuration}; +#[cfg(target_arch = "wasm32")] +pub(crate) use fetch::fetch; pub use path_prefix::*;