Skip to content

Commit

Permalink
feat(routing): ✨ moved subsequent load head generation to server-side
Browse files Browse the repository at this point in the history
Decreases client computational load, improves snappiness, and eliminates head load lag.

Closes #15.
  • Loading branch information
arctic-hen7 committed Sep 21, 2021
1 parent d8fd43f commit 1e02ca4
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 45 deletions.
15 changes: 7 additions & 8 deletions docs/next/src/advanced/subsequent-loads.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ if the user follows a link inside a Perseus app to another page within that same
5. App shell fetches page data from `/.perseus/page/<locale>/about?template_name=about` (if the app isn't using i18n, `<locale>` will verbatim be `xx-XX`).
6. Server checks to ensure that locale is supported.
7. Server renders page using internal systems (in this case that will just return the static HTML file from `.perseus/dist/static/`).
8. Server returns JSON of HTML snippet (not complete file) and stringified properties.
9. App shell deserializes page data into state and HTML snippet.
10. App shell interpolates HTML snippet directly into `__perseus_content_rx` (which Sycamore router controls), user can now see new page.
11. App shell initializes translator if the app is using i18n.
12. App shell renders new `<head>` and updates it (needs the translator to do this).
13. App shell hydrates content at `__perseus_content_rx`, page is now interactive.
8. Server renders document `<head>`.
9. Server returns JSON of HTML snippet (not complete file), stringified properties, and head.
10. App shell deserializes page data into state and HTML snippet.
11. App shell interpolates HTML snippet directly into `__perseus_content_rx` (which Sycamore router controls), user can now see new page.
12. App shell interpolates new document `<head>`.
13. App shell initializes translator if the app is using i18n.
14. App shell hydrates content at `__perseus_content_rx`, page is now interactive.

The two files integral to this process are [`page_data.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus-actix-web/src/page_data.rs) and [`shell.rs`](https://github.com/arctic-hen7/perseus/blob/main/packages/perseus/src/shell.rs).

Note that this process still has one improvement to be made before v0.2.0: rendering the document head on the server and interpolating that simultaneously before the translator is fetched on the client side. This further eliminates the need for rendering the `<head>` on the client at all. The tracking issue is [here](https://github.com/arctic-hen7/perseus/issues/15).
52 changes: 42 additions & 10 deletions packages/perseus-actix-web/src/page_data.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
use crate::conv_req::convert_req;
use crate::Options;
use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse};
use perseus::{err_to_status_code, get_page, ConfigManager, TranslationsManager};
use perseus::{
err_to_status_code,
serve::{get_page_for_template_and_translator, PageDataWithHead},
ConfigManager, TranslationsManager,
};
use serde::Deserialize;
use std::rc::Rc;

#[derive(Deserialize)]
pub struct PageDataReq {
Expand Down Expand Up @@ -33,25 +38,52 @@ pub async fn page_data<C: ConfigManager, T: TranslationsManager>(
.body(err.to_string())
}
};
let page_data = get_page(
// Create a translator here, we'll use it twice
let translator_raw = translations_manager
.get_translator_for_locale(locale.to_string())
.await;
let translator_raw = match translator_raw {
Ok(translator_raw) => translator_raw,
Err(err) => {
// We know the locale is valid, so any failure here is a 500
return HttpResponse::InternalServerError().body(err.to_string());
}
};
let translator = Rc::new(translator_raw);
// Get the template to use
let template = templates.get(&template_name);
let template = match template {
Some(template) => template,
None => {
// We know the template has been pre-routed and should exist, so any failure here is a 500
return HttpResponse::InternalServerError().body("template not found".to_string());
}
};
let page_data = get_page_for_template_and_translator(
path,
locale,
&template_name,
template,
http_req,
templates,
Rc::clone(&translator),
config_manager.get_ref(),
translations_manager.get_ref(),
)
.await;

match page_data {
Ok(page_data) => HttpResponse::Ok().body(serde_json::to_string(&page_data).unwrap()),
let page_data = match page_data {
Ok(page_data) => page_data,
// We parse the error to return an appropriate status code
Err(err) => {
HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap())
return HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap())
.body(err.to_string())
}
}
};
let head_str = template.render_head_str(page_data.state.clone(), Rc::clone(&translator));
let page_data_with_head = PageDataWithHead {
content: page_data.content,
state: page_data.state,
head: head_str,
};

HttpResponse::Ok().body(serde_json::to_string(&page_data_with_head).unwrap())
} else {
HttpResponse::NotFound().body("locale not supported".to_string())
}
Expand Down
12 changes: 12 additions & 0 deletions packages/perseus/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ pub struct PageData {
pub state: Option<String>,
}

/// Represents the data necessary to render a page with its metadata.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PageDataWithHead {
/// Prerendered HTML content.
pub content: String,
/// The state for hydration. This is kept as a string for ease of typing. Some pages may not need state or generate it in another way,
/// so this might be `None`.
pub state: Option<String>,
/// The string to interpolate into the document's `<head>`.
pub head: String,
}

/// Gets the configuration of how to render each page.
pub async fn get_render_cfg(
config_manager: &impl ConfigManager,
Expand Down
49 changes: 22 additions & 27 deletions packages/perseus/src/shell.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::error_pages::ErrorPageData;
use crate::errors::*;
use crate::serve::PageData;
use crate::serve::PageDataWithHead;
use crate::template::Template;
use crate::ClientTranslationsManager;
use crate::ErrorPages;
Expand Down Expand Up @@ -200,12 +200,32 @@ pub async fn app_shell(
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);
let page_data = serde_json::from_str::<PageDataWithHead>(&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);

// Now that the user can see something, we can get the translator
let mut translations_manager_mut =
Expand All @@ -226,31 +246,6 @@ pub async fn app_shell(
}
};

// Render the document head
let head_str = template.render_head_str(
page_data.state.clone(),
Rc::clone(&translator),
);
// 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], head_str
);
head_elem.set_inner_html(&new_head);

// Hydrate that static code using the acquired state
// BUG (Sycamore): this will double-render if the component is just text (no nodes)
sycamore::hydrate_to(
Expand Down

0 comments on commit 1e02ca4

Please sign in to comment.