SSR optimizations for binary size, and flat router

This commit is contained in:
Greg Johnston 2024-04-26 17:36:14 -04:00
parent 2934c295b5
commit 2470637b0b
16 changed files with 13066 additions and 147 deletions

View file

@ -31,7 +31,9 @@ ssr = [
"dep:tower-http",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"dep:leptos_axum",
"routing/ssr",
]
[profile.wasm-release]
@ -60,7 +62,7 @@ style-file = "style/main.scss"
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
site-addr = "127.0.0.1:3007"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.

View file

@ -7,7 +7,7 @@ use leptos::{
};
use leptos_meta::*;
use routing::{
components::{Route, Router, Routes},
components::{FlatRoutes, Route, Router},
hooks::use_params,
params::Params,
ParamSegment, SsrMode, StaticSegment,
@ -24,11 +24,10 @@ pub fn App() -> impl IntoView {
view! {
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
<Title text="Welcome to Leptos"/>
<Meta name="color-scheme" content="dark light"/>
<Router>
<main>
// TODO should fallback be on Routes or Router?
<Routes fallback>
<FlatRoutes fallback>
// Well load the home page with out-of-order streaming and <Suspense/>
<Route path=StaticSegment("") view=HomePage/>
@ -44,7 +43,7 @@ pub fn App() -> impl IntoView {
view=Post
ssr=SsrMode::InOrder
/>
</Routes>
</FlatRoutes>
</main>
</Router>
}

File diff suppressed because one or more lines are too long

View file

@ -68,6 +68,7 @@ rustls = ["leptos_server/rustls", "server_fn/rustls"]
ssr = [
"leptos_macro/ssr",
"leptos_reactive/ssr",
"leptos_server/ssr",
"server_fn/ssr",
"hydration",
"tachys/ssr",

View file

@ -39,6 +39,7 @@ base64 = { version = "0.22", optional = true }
leptos = { path = "../leptos" }
[features]
ssr = []
default-tls = ["server_fn/default-tls"]
rustls = ["server_fn/rustls"]
hydration = ["reactive_graph/hydration", "dep:serde", "dep:serde_json"]

View file

@ -187,6 +187,7 @@ where
source.add_subscriber(data.to_any_subscriber());
}
#[cfg(feature = "ssr")]
if let Some(shared_context) = shared_context {
let value = data.clone();
let ready_fut = data.ready();
@ -408,7 +409,7 @@ where
T: Send + Sync + 'static,
Fut: Future<Output = T> + Send + 'static,
{
let ArcResource { ser, data } =
let ArcResource { data, .. } =
ArcResource::new_with_encoding(source, fetcher);
Resource {
ser: PhantomData,

View file

@ -24,6 +24,7 @@ features = ["HtmlLinkElement", "HtmlMetaElement", "HtmlTitleElement"]
[features]
default = []
ssr = []
[package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]

View file

@ -288,6 +288,7 @@ where
{
let mut el = Some(el);
#[cfg(feature = "ssr")]
if let Some(cx) = use_context::<ServerMetaContext>() {
let mut inner = cx.inner.write().or_poisoned();
el.take()
@ -458,78 +459,3 @@ impl RenderHtml<Dom> for MetaTagsView {
) -> Self::State {
}
}
impl MetaContext {
// TODO remove the below?
#[cfg(feature = "ssr")]
/// Converts the existing metadata tags into HTML that can be injected into the document head.
///
/// This should be called *after* the apps component tree has been rendered into HTML, so that
/// components can set meta tags.
///
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// # #[cfg(not(any(feature = "csr", feature = "hydrate")))] {
/// # let runtime = create_runtime();
/// provide_meta_context();
///
/// let app = view! {
/// <main>
/// <Title text="my title"/>
/// <Stylesheet href="/style.css"/>
/// <p>"Some text"</p>
/// </main>
/// };
///
/// // `app` contains only the body content w/ hydration stuff, not the meta tags
/// assert!(
/// !app.into_view().render_to_string().contains("my title")
/// );
/// // `MetaContext::dehydrate()` gives you HTML that should be in the `<head>`
/// assert!(use_head().dehydrate().contains("<title>my title</title>"));
/// # runtime.dispose();
/// # }
/// ```
pub fn dehydrate(&self) -> String {
let mut tags = String::new();
// Title
if let Some(title) = self.title.as_string() {
tags.push_str("<title>");
tags.push_str(&title);
tags.push_str("</title>");
}
tags.push_str(&self.tags.as_string());
tags
}
}
/// Extracts the metadata that should be used to close the `<head>` tag
/// and open the `<body>` tag. This is a helper function used in implementing
/// server-side HTML rendering across crates.
#[cfg(feature = "ssr")]
pub fn generate_head_metadata() -> String {
let (head, body) = generate_head_metadata_separated();
format!("{head}</head>{body}")
}
/// Extracts the metadata that should be inserted at the beginning of the `<head>` tag
/// and on the opening `<body>` tag. This is a helper function used in implementing
/// server-side HTML rendering across crates.
#[cfg(feature = "ssr")]
pub fn generate_head_metadata_separated() -> (String, String) {
let meta = use_context::<MetaContext>();
let head = meta
.as_ref()
.map(|meta| meta.dehydrate())
.unwrap_or_default();
let body_meta = meta
.as_ref()
.and_then(|meta| meta.body.as_string())
.unwrap_or_default();
(head, format!("<body{body_meta}>"))
}

View file

@ -49,4 +49,5 @@ features = [
[features]
tracing = ["dep:tracing"]
ssr = []
nightly = []

View file

@ -6,7 +6,8 @@ use crate::{
},
navigate::{NavigateOptions, UseNavigate},
resolve_path::resolve_path,
MatchNestedRoutes, NestedRoute, NestedRoutesView, Routes, SsrMode,
FlatRoutesView, MatchNestedRoutes, NestedRoute, NestedRoutesView, Routes,
SsrMode,
};
use leptos::{
children::{ToChildren, TypedChildren},
@ -70,19 +71,20 @@ pub fn Router<Chil>(
where
Chil: IntoView,
{
let current_url = if Owner::current_shared_context()
.map(|sc| sc.is_browser())
.unwrap_or(false)
{
let location = BrowserUrl::new().expect("could not access browser navigation"); // TODO options here
location.init(base.clone());
location.as_url().clone()
} else {
#[cfg(feature = "ssr")]
let current_url = {
let req = use_context::<RequestUrl>().expect("no RequestUrl provided");
let parsed = req.parse().expect("could not parse RequestUrl");
ArcRwSignal::new(parsed)
};
#[cfg(not(feature = "ssr"))]
let current_url = {
let location =
BrowserUrl::new().expect("could not access browser navigation"); // TODO options here
location.init(base.clone());
location.as_url().clone()
};
// provide router context
let state = ArcRwSignal::new(State::new(None));
let location = Location::new(current_url.read_only(), state.read_only());
@ -220,7 +222,7 @@ where
move |_| url.read().search_params().clone()
});
let outer_owner =
Owner::current().expect("creating Router, but no Owner was found");
Owner::current().expect("creating Routes, but no Owner was found");
move || NestedRoutesView {
routes: routes.clone(),
outer_owner: outer_owner.clone(),
@ -233,6 +235,46 @@ where
}
}
#[component]
pub fn FlatRoutes<Defs, FallbackFn, Fallback>(
fallback: FallbackFn,
children: RouteChildren<Defs>,
) -> impl IntoView
where
Defs: MatchNestedRoutes<Dom> + Clone + Send + 'static,
FallbackFn: Fn() -> Fallback + Send + 'static,
Fallback: IntoView + 'static,
{
use either_of::Either;
let RouterContext {
current_url, base, ..
} = use_context()
.expect("<FlatRoutes> should be used inside a <Router> component");
let base = base.map(|base| {
let mut base = Oco::from(base);
base.upgrade_inplace();
base
});
let routes = Routes::new(children.into_inner());
let path = ArcMemo::new({
let url = current_url.clone();
move |_| url.read().path().to_string()
});
let search_params = ArcMemo::new({
let url = current_url.clone();
move |_| url.read().search_params().clone()
});
let outer_owner =
Owner::current().expect("creating Router, but no Owner was found");
move || FlatRoutesView {
routes: routes.clone(),
path: path.clone(),
fallback: fallback(),
outer_owner: outer_owner.clone(),
}
}
#[component]
pub fn Route<Segments, View, ViewFn>(
path: Segments,

189
routing/src/flat_router.rs Normal file
View file

@ -0,0 +1,189 @@
use crate::{
location::{Location, RequestUrl, Url},
matching::Routes,
params::ParamsMap,
resolve_path::resolve_path,
ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, Method,
PathSegment, RouteList, RouteListing, RouteMatchId,
};
use either_of::Either;
use leptos::{component, oco::Oco, IntoView};
use or_poisoned::OrPoisoned;
use reactive_graph::{
computed::{ArcMemo, Memo},
owner::{provide_context, use_context, Owner},
signal::{ArcRwSignal, ArcTrigger},
traits::{Get, Read, ReadUntracked, Set, Track, Trigger},
};
use std::{
borrow::Cow,
iter,
marker::PhantomData,
mem,
sync::{
mpsc::{self, Receiver, Sender},
Arc, Mutex,
},
};
use tachys::{
hydration::Cursor,
renderer::Renderer,
ssr::StreamBuilder,
view::{
any_view::{AnyView, AnyViewState, IntoAny},
either::EitherState,
Mountable, Position, PositionState, Render, RenderHtml,
},
};
pub(crate) struct FlatRoutesView<Defs, Fal, R> {
pub routes: Routes<Defs, R>,
pub path: ArcMemo<String>,
pub fallback: Fal,
pub outer_owner: Owner,
}
impl<Defs, Fal, R> FlatRoutesView<Defs, Fal, R>
where
Defs: MatchNestedRoutes<R>,
Fal: Render<R>,
R: Renderer + 'static,
{
pub fn choose(
self,
) -> Either<Fal, <Defs::Match as MatchInterface<R>>::View> {
let FlatRoutesView {
routes,
path,
fallback,
outer_owner,
} = self;
outer_owner.with(|| {
let new_match = routes.match_route(&path.read());
match new_match {
None => Either::Left(fallback),
Some(matched) => {
let params = matched.to_params();
let (view, child) = matched.into_view_and_child();
#[cfg(debug_assertions)]
if child.is_some() {
panic!(
"<FlatRoutes> should not be used with nested \
routes."
);
}
let view = view.choose();
Either::Right(view)
}
}
})
}
}
impl<Defs, Fal, R> Render<R> for FlatRoutesView<Defs, Fal, R>
where
Defs: MatchNestedRoutes<R>,
Fal: Render<R>,
R: Renderer + 'static,
{
type State = <Either<Fal, <Defs::Match as MatchInterface<R>>::View> as Render<R>>::State;
fn build(self) -> Self::State {
self.choose().build()
}
fn rebuild(self, state: &mut Self::State) {
self.choose().rebuild(state);
}
}
impl<Defs, Fal, R> RenderHtml<R> for FlatRoutesView<Defs, Fal, R>
where
Defs: MatchNestedRoutes<R> + Send,
Fal: RenderHtml<R>,
R: Renderer + 'static,
{
type AsyncOutput = Self;
const MIN_LENGTH: usize = <Either<
Fal,
<Defs::Match as MatchInterface<R>>::View,
> as RenderHtml<R>>::MIN_LENGTH;
async fn resolve(self) -> Self::AsyncOutput {
self
}
fn to_html_with_buf(self, buf: &mut String, position: &mut Position) {
// if this is being run on the server for the first time, generating all possible routes
if RouteList::is_generating() {
// add routes
let (base, routes) = self.routes.generate_routes();
let mut routes = routes
.into_iter()
.map(|data| {
let path = base
.into_iter()
.flat_map(|base| {
iter::once(PathSegment::Static(
base.to_string().into(),
))
})
.chain(data.segments)
.collect::<Vec<_>>();
RouteListing::new(
path,
data.ssr_mode,
// TODO methods
[Method::Get],
// TODO static data
None,
)
})
.collect::<Vec<_>>();
// add fallback
// TODO fix: causes overlapping route issues on Axum
/*routes.push(RouteListing::new(
[PathSegment::Static(
base.unwrap_or_default().to_string().into(),
)],
SsrMode::Async,
[
Method::Get,
Method::Post,
Method::Put,
Method::Patch,
Method::Delete,
],
None,
));*/
RouteList::register(RouteList::from(routes));
} else {
self.choose().to_html_with_buf(buf, position);
}
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
self,
buf: &mut StreamBuilder,
position: &mut Position,
) where
Self: Sized,
{
self.choose()
.to_html_async_with_buf::<OUT_OF_ORDER>(buf, position);
}
fn hydrate<const FROM_SERVER: bool>(
self,
cursor: &Cursor<R>,
position: &PositionState,
) -> Self::State {
self.choose().hydrate::<FROM_SERVER>(cursor, position)
}
}

View file

@ -3,6 +3,7 @@
#![cfg_attr(feature = "nightly", feature(negative_impls))]
pub mod components;
mod flat_router;
mod generate_route_list;
pub mod hooks;
pub mod link;
@ -16,6 +17,7 @@ pub mod params;
mod ssr_mode;
mod static_route;
pub use flat_router::*;
pub use generate_route_list::*;
pub use matching::*;
pub use method::*;

View file

@ -26,13 +26,13 @@ use std::{
},
};
use tachys::{
hydration::Cursor, view::PositionState,
hydration::Cursor,
renderer::Renderer,
ssr::StreamBuilder,
view::{
any_view::{AnyView, AnyViewState, IntoAny},
either::EitherState,
Mountable, Position, Render, RenderHtml,
Mountable, Position, PositionState, Render, RenderHtml,
},
};
@ -182,8 +182,6 @@ where
})
.collect::<Vec<_>>();
println!("routes = {routes:#?}");
// add fallback
// TODO fix: causes overlapping route issues on Axum
/*routes.push(RouteListing::new(
@ -203,31 +201,31 @@ where
RouteList::register(RouteList::from(routes));
} else {
let NestedRoutesView {
routes,
outer_owner,
url,
path,
search_params,
fallback,
base,
..
} = self;
let NestedRoutesView {
routes,
outer_owner,
url,
path,
search_params,
fallback,
base,
..
} = self;
let mut outlets = Vec::new();
let new_match = routes.match_route(&path.read());
let view = match new_match {
None => Either::Left(fallback),
Some(route) => {
route.build_nested_route(base, &mut outlets, &outer_owner);
outer_owner.with(|| {
Either::Right(
Outlet(OutletProps::builder().build()).into_any(),
)
})
}
};
view.to_html_with_buf(buf, position);
let mut outlets = Vec::new();
let new_match = routes.match_route(&path.read());
let view = match new_match {
None => Either::Left(fallback),
Some(route) => {
route.build_nested_route(base, &mut outlets, &outer_owner);
outer_owner.with(|| {
Either::Right(
Outlet(OutletProps::builder().build()).into_any(),
)
})
}
};
view.to_html_with_buf(buf, position);
}
}

View file

@ -401,9 +401,9 @@ where
// hydrate children
position.set(Position::FirstChild);
let children = self.children.hydrate::<FROM_SERVER>(cursor, position);
cursor.set(el.as_ref().clone());
// go to next sibling
cursor.set(el.as_ref().clone());
position.set(Position::NextChild);
ElementState {

View file

@ -32,10 +32,6 @@ where
#[allow(clippy::type_complexity)]
hydrate_from_server:
fn(Box<dyn Any>, &Cursor<R>, &PositionState) -> AnyViewState<R>,
#[cfg(feature = "hydrate")]
#[allow(clippy::type_complexity)]
hydrate_from_template:
fn(Box<dyn Any>, &Cursor<R>, &PositionState) -> AnyViewState<R>,
}
pub struct AnyViewState<R>
@ -195,25 +191,7 @@ where
insert_before_this: insert_before_this::<R, T>,
}
};
#[cfg(feature = "hydrate")]
let hydrate_from_template =
|value: Box<dyn Any>,
cursor: &Cursor<R>,
position: &PositionState| {
let value = value
.downcast::<T>()
.expect("AnyView::hydrate_from_server couldn't downcast");
let state = Box::new(value.hydrate::<true>(cursor, position));
AnyViewState {
type_id: TypeId::of::<T>(),
state,
rndr: PhantomData,
mount: mount_any::<R, T>,
unmount: unmount_any::<R, T>,
insert_before_this: insert_before_this::<R, T>,
}
};
let rebuild = |new_type_id: TypeId,
value: Box<dyn Any>,
state: &mut AnyViewState<R>| {
@ -250,8 +228,6 @@ where
to_html_async_ooo,
#[cfg(feature = "hydrate")]
hydrate_from_server,
#[cfg(feature = "hydrate")]
hydrate_from_template,
}
}
}
@ -331,7 +307,10 @@ where
if FROM_SERVER {
(self.hydrate_from_server)(self.value, cursor, position)
} else {
(self.hydrate_from_template)(self.value, cursor, position)
panic!(
"hydrating AnyView from inside a ViewTemplate is not \
supported."
);
}
#[cfg(not(feature = "hydrate"))]
{

View file

@ -507,7 +507,9 @@ macro_rules! tuples {
position: &PositionState,
) -> Self::State {
let state = match self {
$([<EitherOf $num>]::$ty(this) => [<EitherOf $num>]::$ty(this.hydrate::<FROM_SERVER>(cursor, position)),)*
$([<EitherOf $num>]::$ty(this) => {
[<EitherOf $num>]::$ty(this.hydrate::<FROM_SERVER>(cursor, position))
})*
};
let marker = cursor.next_placeholder(position);