mirror of
https://github.com/chenasraf/leptos.git
synced 2026-05-17 17:48:10 +00:00
feature: in-order streaming and async rendering (#496)
This commit is contained in:
@@ -11,6 +11,7 @@ members = [
|
||||
# integrations
|
||||
"integrations/actix",
|
||||
"integrations/axum",
|
||||
"integrations/utils",
|
||||
|
||||
# libraries
|
||||
"meta",
|
||||
@@ -30,6 +31,7 @@ leptos_server = { path = "./leptos_server", default-features = false, version =
|
||||
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0-alpha" }
|
||||
leptos_router = { path = "./router", version = "0.2.0-alpha" }
|
||||
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0-alpha" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.0-alpha" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
13
examples/ssr_modes/.gitignore
vendored
Normal file
13
examples/ssr_modes/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
pkg
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# node e2e test tools and outputs
|
||||
node_modules/
|
||||
test-results/
|
||||
end2end/playwright-report/
|
||||
playwright/.cache/
|
||||
88
examples/ssr_modes/Cargo.toml
Normal file
88
examples/ssr_modes/Cargo.toml
Normal file
@@ -0,0 +1,88 @@
|
||||
[package]
|
||||
name = "ssr_modes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "0.2"
|
||||
cfg-if = "1"
|
||||
lazy_static = "1"
|
||||
leptos = { path = "../../leptos", default-features = false, features = [
|
||||
"serde",
|
||||
] }
|
||||
leptos_meta = { path = "../../meta", default-features = false }
|
||||
leptos_actix = { path = "../../integrations/actix", default-features = false, optional = true }
|
||||
leptos_router = { path = "../../router", default-features = false }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
simple_logger = "4"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:actix-files",
|
||||
"dep:actix-web",
|
||||
"dep:leptos_actix",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "ssr_modes"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
# 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"
|
||||
# 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.
|
||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||
# This binary name can be checked in Powershell with Get-Command npx
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
21
examples/ssr_modes/LICENSE
Normal file
21
examples/ssr_modes/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 henrik
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
54
examples/ssr_modes/README.md
Normal file
54
examples/ssr_modes/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Server-Side Rendering Modes
|
||||
|
||||
This example shows the different "rendering modes" that can be used while server-side
|
||||
rendering an application:
|
||||
1. **Synchronous**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the client, replacing `fallback` once they're loaded.
|
||||
- *Pros*: App shell appears very quickly: great TTFB (time to first byte).
|
||||
- *Cons*: Resources load relatively slowly; you need to wait for JS + Wasm to load before even making a request.
|
||||
2. **Out-of-order streaming**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the **server**, streaming it down to the client as it resolves, and streaming down HTML for `Suspense` nodes.
|
||||
- *Pros*: Combines the best of **synchronous** and **`async`**, with a very fast shell and resources that begin loading on the server.
|
||||
- *Cons*: Requires JS for suspended fragments to appear in correct order. Weaker meta tag support when it depends on data that's under suspense (has already streamed down `<head>`)
|
||||
3. **In-order streaming**: Walk through the tree, returning HTML synchronously as in synchronous rendering and out-of-order streaming until you hit a `Suspense`. At that point, wait for all its data to load, then render it, then the rest of the tree.
|
||||
- *Pros*: Does not require JS for HTML to appear in correct order.
|
||||
- *Cons*: Loads the shell more slowly than out-of-order streaming or synchronous rendering because it needs to pause at every `Suspense`. Cannot begin hydration until the entire page has loaded, so earlier pieces
|
||||
of the page will not be interactive until the suspended chunks have loaded.
|
||||
4. **`async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
|
||||
- *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server.
|
||||
- *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client.
|
||||
|
||||
## Server Side Rendering with `cargo-leptos`
|
||||
`cargo-leptos` is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
|
||||
|
||||
1. Install cargo-leptos
|
||||
```bash
|
||||
cargo install --locked cargo-leptos
|
||||
```
|
||||
2. Build the site in watch mode, recompiling on file changes
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Open browser on [http://localhost:3000/](http://localhost:3000/)
|
||||
|
||||
3. When ready to deploy, run
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
## Server Side Rendering without cargo-leptos
|
||||
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
|
||||
|
||||
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
|
||||
1. Install wasm-pack
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
2. Build the Webassembly used to hydrate the HTML from the server
|
||||
```bash
|
||||
wasm-pack build --target=web --debug --no-default-features --features=hydrate
|
||||
```
|
||||
3. Run the server to serve the Webassembly, JS, and HTML
|
||||
```bash
|
||||
cargo run --no-default-features --features=ssr
|
||||
```
|
||||
|
||||
BIN
examples/ssr_modes/assets/favicon.ico
Normal file
BIN
examples/ssr_modes/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
184
examples/ssr_modes/src/app.rs
Normal file
184
examples/ssr_modes/src/app.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use lazy_static::lazy_static;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context(cx);
|
||||
|
||||
view! { cx,
|
||||
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
<Router>
|
||||
<main>
|
||||
<Routes>
|
||||
// We’ll load the home page with out-of-order streaming and <Suspense/>
|
||||
<Route path="" view=|cx| view! { cx, <HomePage/> }/>
|
||||
|
||||
// We'll load the posts with async rendering, so they can set
|
||||
// the title and metadata *after* loading the data
|
||||
<Route
|
||||
path="/post/:id"
|
||||
view=|cx| view! { cx, <Post/> }
|
||||
ssr=SsrMode::Async
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HomePage(cx: Scope) -> impl IntoView {
|
||||
// load the posts
|
||||
let posts =
|
||||
create_resource(cx, || (), |_| async { list_post_metadata().await });
|
||||
let posts_view = move || {
|
||||
posts.with(|posts| posts
|
||||
.clone()
|
||||
.map(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! { cx, <li><a href=format!("/post/{}", post.id)>{&post.title}</a></li>})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<h1>"My Great Blog"</h1>
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading posts..."</p> }>
|
||||
<ul>{posts_view}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Params, Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PostParams {
|
||||
id: usize,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Post(cx: Scope) -> impl IntoView {
|
||||
let query = use_params::<PostParams>(cx);
|
||||
let id = move || {
|
||||
query.with(|q| {
|
||||
q.as_ref().map(|q| q.id).map_err(|_| PostError::InvalidId)
|
||||
})
|
||||
};
|
||||
let post = create_resource(cx, id, |id| async move {
|
||||
match id {
|
||||
Err(e) => Err(e),
|
||||
Ok(id) => get_post(id)
|
||||
.await
|
||||
.map(|data| data.ok_or(PostError::PostNotFound))
|
||||
.map_err(|_| PostError::ServerError)
|
||||
.flatten(),
|
||||
}
|
||||
});
|
||||
|
||||
let post_view = move || {
|
||||
post.with(|post| {
|
||||
post.clone().map(|post| {
|
||||
view! { cx,
|
||||
// render content
|
||||
<h1>{&post.title}</h1>
|
||||
<p>{&post.content}</p>
|
||||
|
||||
// since we're using async rendering for this page,
|
||||
// this metadata should be included in the actual HTML <head>
|
||||
// when it's first served
|
||||
<Title text=post.title/>
|
||||
<Meta name="description" content=post.content/>
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<Suspense fallback=move || view! { cx, <p>"Loading post..."</p> }>
|
||||
<ErrorBoundary fallback=|cx, errors| {
|
||||
view! { cx,
|
||||
<div class="error">
|
||||
<h1>"Something went wrong."</h1>
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, error)| view! { cx, <li>{error.to_string()} </li> })
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}>
|
||||
{post_view}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
// Dummy API
|
||||
lazy_static! {
|
||||
static ref POSTS: Vec<Post> = vec![
|
||||
Post {
|
||||
id: 0,
|
||||
title: "My first post".to_string(),
|
||||
content: "This is my first post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 1,
|
||||
title: "My second post".to_string(),
|
||||
content: "This is my second post".to_string(),
|
||||
},
|
||||
Post {
|
||||
id: 2,
|
||||
title: "My third post".to_string(),
|
||||
content: "This is my third post".to_string(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PostError {
|
||||
#[error("Invalid post ID.")]
|
||||
InvalidId,
|
||||
#[error("Post not found.")]
|
||||
PostNotFound,
|
||||
#[error("Server error.")]
|
||||
ServerError,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
id: usize,
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PostMetadata {
|
||||
id: usize,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[server(ListPostMetadata, "/api")]
|
||||
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS
|
||||
.iter()
|
||||
.map(|data| PostMetadata {
|
||||
id: data.id,
|
||||
title: data.title.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[server(GetPost, "/api")]
|
||||
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS.iter().find(|post| post.id == id).cloned())
|
||||
}
|
||||
25
examples/ssr_modes/src/lib.rs
Normal file
25
examples/ssr_modes/src/lib.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
#![feature(result_flattening)]
|
||||
|
||||
pub mod app;
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "hydrate")] {
|
||||
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use app::*;
|
||||
use leptos::*;
|
||||
|
||||
// initializes logging using the `log` crate
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(move |cx| {
|
||||
view! { cx, <App/> }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
42
examples/ssr_modes/src/main.rs
Normal file
42
examples/ssr_modes/src/main.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
use actix_files::Files;
|
||||
use actix_web::*;
|
||||
use leptos::*;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
use ssr_modes::app::*;
|
||||
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(|cx| view! { cx, <App/> });
|
||||
|
||||
GetPost::register();
|
||||
ListPostMetadata::register();
|
||||
|
||||
HttpServer::new(move || {
|
||||
let leptos_options = &conf.leptos_options;
|
||||
let site_root = &leptos_options.site_root;
|
||||
|
||||
App::new()
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(
|
||||
leptos_options.to_owned(),
|
||||
routes.to_owned(),
|
||||
|cx| view! { cx, <App/> },
|
||||
)
|
||||
.service(Files::new("/", site_root))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub fn main() {
|
||||
// no client-side main function
|
||||
// unless we want this to work with e.g., Trunk for pure client-side testing
|
||||
// see lib.rs for hydration function instead
|
||||
}
|
||||
3
examples/ssr_modes/style/main.scss
Normal file
3
examples/ssr_modes/style/main.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ console_error_panic_hook = "0.1.7"
|
||||
uuid = { version = "1", features = ["v4", "js", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
web-sys = { version = "0.3", features = ["Storage"] }
|
||||
web-sys = { version = "0.3.60", features = ["Storage"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
|
||||
@@ -13,5 +13,6 @@ futures = "0.3"
|
||||
leptos = { workspace = true, features = ["ssr"] }
|
||||
leptos_meta = { workspace = true, features = ["ssr"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_integration_utils = { workspace = true }
|
||||
parking_lot = "0.12.1"
|
||||
regex = "1.7.0"
|
||||
|
||||
@@ -13,13 +13,14 @@ use actix_web::{
|
||||
web::Bytes,
|
||||
*,
|
||||
};
|
||||
use futures::{Future, StreamExt};
|
||||
use futures::{Future, Stream, StreamExt};
|
||||
use http::StatusCode;
|
||||
use leptos::{
|
||||
leptos_dom::ssr::render_to_stream_with_prefix_undisposed_with_context,
|
||||
leptos_server::{server_fn_by_path, Payload},
|
||||
*,
|
||||
};
|
||||
use leptos_integration_utils::{build_async_response, html_parts};
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use parking_lot::RwLock;
|
||||
@@ -274,7 +275,9 @@ pub fn handle_server_fns_with_context(
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an HTML stream of your application.
|
||||
/// to route it using [leptos_router], serving an HTML stream of your application. The stream
|
||||
/// will include fallback content for any `<Suspense/>` nodes, and be immediately interactive,
|
||||
/// but requires some client-side JavaScript.
|
||||
///
|
||||
/// The provides a [MetaContext] and a [RouterIntegrationContext] to app’s context before
|
||||
/// rendering it, and includes any meta tags injected using [leptos_meta].
|
||||
@@ -335,6 +338,133 @@ where
|
||||
render_app_to_stream_with_context(options, |_cx| {}, app_fn)
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an in-order HTML stream of your application.
|
||||
/// This stream will pause at each `<Suspense/>` node and wait for it to resolve befores
|
||||
/// sending down its HTML. The app will become interactive once it has fully loaded.
|
||||
///
|
||||
/// The provides a [MetaContext] and a [RouterIntegrationContext] to app’s context before
|
||||
/// rendering it, and includes any meta tags injected using [leptos_meta].
|
||||
///
|
||||
/// The HTML stream is rendered using [render_to_stream_in_order], and includes everything described in
|
||||
/// the documentation for that function.
|
||||
///
|
||||
/// This can then be set up at an appropriate route in your application:
|
||||
/// ```
|
||||
/// use actix_web::{App, HttpServer};
|
||||
/// use leptos::*;
|
||||
/// use std::{env, net::SocketAddr};
|
||||
///
|
||||
/// #[component]
|
||||
/// fn MyApp(cx: Scope) -> impl IntoView {
|
||||
/// view! { cx, <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # if false { // don't actually try to run a server in a doctest...
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() -> std::io::Result<()> {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
||||
/// let addr = conf.leptos_options.site_addr.clone();
|
||||
/// HttpServer::new(move || {
|
||||
/// let leptos_options = &conf.leptos_options;
|
||||
///
|
||||
/// App::new()
|
||||
/// // {tail:.*} passes the remainder of the URL as the route
|
||||
/// // the actual routing will be handled by `leptos_router`
|
||||
/// .route(
|
||||
/// "/{tail:.*}",
|
||||
/// leptos_actix::render_app_to_stream_in_order(
|
||||
/// leptos_options.to_owned(),
|
||||
/// |cx| view! { cx, <MyApp/> },
|
||||
/// ),
|
||||
/// )
|
||||
/// })
|
||||
/// .bind(&addr)?
|
||||
/// .run()
|
||||
/// .await
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [ResponseOptions]
|
||||
/// - [HttpRequest](actix_web::HttpRequest)
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_to_stream_in_order<IV>(
|
||||
options: LeptosOptions,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
|
||||
) -> Route
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
render_app_to_stream_in_order_with_context(options, |_cx| {}, app_fn)
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
|
||||
/// `async` [Resource](leptos::Resource)s have loaded.
|
||||
///
|
||||
/// The provides a [MetaContext] and a [RouterIntegrationContext] to the app’s context before
|
||||
/// rendering it, and includes any meta tags injected using [leptos_meta].
|
||||
///
|
||||
/// The HTML stream is rendered using [render_to_string_async], and includes everything described in
|
||||
/// the documentation for that function.
|
||||
///
|
||||
/// This can then be set up at an appropriate route in your application:
|
||||
/// ```
|
||||
/// use actix_web::{App, HttpServer};
|
||||
/// use leptos::*;
|
||||
/// use std::{env, net::SocketAddr};
|
||||
///
|
||||
/// #[component]
|
||||
/// fn MyApp(cx: Scope) -> impl IntoView {
|
||||
/// view! { cx, <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # if false { // don't actually try to run a server in a doctest...
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() -> std::io::Result<()> {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
||||
/// let addr = conf.leptos_options.site_addr.clone();
|
||||
/// HttpServer::new(move || {
|
||||
/// let leptos_options = &conf.leptos_options;
|
||||
///
|
||||
/// App::new()
|
||||
/// // {tail:.*} passes the remainder of the URL as the route
|
||||
/// // the actual routing will be handled by `leptos_router`
|
||||
/// .route(
|
||||
/// "/{tail:.*}",
|
||||
/// leptos_actix::render_app_async(
|
||||
/// leptos_options.to_owned(),
|
||||
/// |cx| view! { cx, <MyApp/> },
|
||||
/// ),
|
||||
/// )
|
||||
/// })
|
||||
/// .bind(&addr)?
|
||||
/// .run()
|
||||
/// .await
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [ResponseOptions]
|
||||
/// - [HttpRequest](actix_web::HttpRequest)
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_async<IV>(
|
||||
options: LeptosOptions,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
|
||||
) -> Route
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
render_app_async_with_context(options, |_cx| {}, app_fn)
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an HTML stream of your application.
|
||||
///
|
||||
@@ -376,6 +506,96 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an in-order HTML stream of your application.
|
||||
///
|
||||
/// This function allows you to provide additional information to Leptos for your route.
|
||||
/// It could be used to pass in Path Info, Connection Info, or anything your heart desires.
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [ResponseOptions]
|
||||
/// - [HttpRequest](actix_web::HttpRequest)
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_to_stream_in_order_with_context<IV>(
|
||||
options: LeptosOptions,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
|
||||
) -> Route
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
web::get().to(move |req: HttpRequest| {
|
||||
let options = options.clone();
|
||||
let app_fn = app_fn.clone();
|
||||
let additional_context = additional_context.clone();
|
||||
let res_options = ResponseOptions::default();
|
||||
|
||||
async move {
|
||||
let app = {
|
||||
let app_fn = app_fn.clone();
|
||||
let res_options = res_options.clone();
|
||||
move |cx| {
|
||||
provide_contexts(cx, &req, res_options);
|
||||
(app_fn)(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
|
||||
stream_app_in_order(&options, app, res_options, additional_context)
|
||||
.await
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], asynchronously serving the page once all `async`
|
||||
/// [Resource](leptos::Resource)s have loaded.
|
||||
///
|
||||
/// This function allows you to provide additional information to Leptos for your route.
|
||||
/// It could be used to pass in Path Info, Connection Info, or anything your heart desires.
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [ResponseOptions]
|
||||
/// - [HttpRequest](actix_web::HttpRequest)
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_async_with_context<IV>(
|
||||
options: LeptosOptions,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
|
||||
) -> Route
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
web::get().to(move |req: HttpRequest| {
|
||||
let options = options.clone();
|
||||
let app_fn = app_fn.clone();
|
||||
let additional_context = additional_context.clone();
|
||||
let res_options = ResponseOptions::default();
|
||||
|
||||
async move {
|
||||
let app = {
|
||||
let app_fn = app_fn.clone();
|
||||
let res_options = res_options.clone();
|
||||
move |cx| {
|
||||
provide_contexts(cx, &req, res_options);
|
||||
(app_fn)(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
|
||||
render_app_async_helper(
|
||||
&options,
|
||||
app,
|
||||
res_options,
|
||||
additional_context,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an HTML stream of your application.
|
||||
///
|
||||
@@ -434,6 +654,9 @@ where
|
||||
/// - [HttpRequest](actix_web::HttpRequest)
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
#[deprecated = "You can now use `render_app_async` with `create_resource` and \
|
||||
`<Suspense/>` to achieve async rendering without manually \
|
||||
preloading data."]
|
||||
pub fn render_preloaded_data_app<Data, Fut, IV>(
|
||||
options: LeptosOptions,
|
||||
data_fn: impl Fn(HttpRequest) -> Fut + Clone + 'static,
|
||||
@@ -504,22 +727,39 @@ async fn stream_app(
|
||||
) -> HttpResponse<BoxBody> {
|
||||
let (stream, runtime, scope) =
|
||||
render_to_stream_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
move |cx| generate_head_metadata(cx).into(),
|
||||
additional_context,
|
||||
);
|
||||
|
||||
build_stream_response(options, res_options, stream, runtime, scope).await
|
||||
}
|
||||
|
||||
async fn stream_app_in_order(
|
||||
options: &LeptosOptions,
|
||||
app: impl FnOnce(leptos::Scope) -> View + 'static,
|
||||
res_options: ResponseOptions,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
) -> HttpResponse<BoxBody> {
|
||||
let (stream, runtime, scope) =
|
||||
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
move |cx| {
|
||||
let meta = use_context::<MetaContext>(cx);
|
||||
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();
|
||||
format!("{head}</head><body{body_meta}>").into()
|
||||
generate_head_metadata(cx).into()
|
||||
},
|
||||
additional_context,
|
||||
);
|
||||
|
||||
build_stream_response(options, res_options, stream, runtime, scope).await
|
||||
}
|
||||
|
||||
async fn build_stream_response(
|
||||
options: &LeptosOptions,
|
||||
res_options: ResponseOptions,
|
||||
stream: impl Stream<Item = String> + 'static,
|
||||
runtime: RuntimeId,
|
||||
scope: ScopeId,
|
||||
) -> HttpResponse {
|
||||
let cx = leptos::Scope { runtime, id: scope };
|
||||
let (head, tail) =
|
||||
html_parts(options, use_context::<MetaContext>(cx).as_ref());
|
||||
@@ -563,69 +803,40 @@ async fn stream_app(
|
||||
res
|
||||
}
|
||||
|
||||
fn html_parts(
|
||||
async fn render_app_async_helper(
|
||||
options: &LeptosOptions,
|
||||
meta_context: Option<&MetaContext>,
|
||||
) -> (String, String) {
|
||||
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
|
||||
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
|
||||
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
|
||||
let output_name = &options.output_name;
|
||||
let mut wasm_output_name = output_name.clone();
|
||||
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
|
||||
wasm_output_name.push_str("_bg");
|
||||
app: impl FnOnce(leptos::Scope) -> View + 'static,
|
||||
res_options: ResponseOptions,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
) -> HttpResponse<BoxBody> {
|
||||
let (stream, runtime, scope) =
|
||||
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
move |_| "".into(),
|
||||
additional_context,
|
||||
);
|
||||
|
||||
let html = build_async_response(stream, options, runtime, scope).await;
|
||||
|
||||
let res_options = res_options.0.read();
|
||||
|
||||
let (status, mut headers) =
|
||||
(res_options.status, res_options.headers.clone());
|
||||
let status = status.unwrap_or_default();
|
||||
|
||||
let mut res = HttpResponse::Ok().content_type("text/html").body(html);
|
||||
|
||||
// Add headers manipulated in the response
|
||||
for (key, value) in headers.drain() {
|
||||
if let Some(key) = key {
|
||||
res.headers_mut().append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
let site_ip = &options.site_addr.ip().to_string();
|
||||
let reload_port = options.reload_port;
|
||||
let pkg_path = &options.site_pkg_dir;
|
||||
|
||||
let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() {
|
||||
true => format!(
|
||||
r#"
|
||||
<script crossorigin="">(function () {{
|
||||
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
ws.onmessage = (ev) => {{
|
||||
let msg = JSON.parse(ev.data);
|
||||
if (msg.all) window.location.reload();
|
||||
if (msg.css) {{
|
||||
let found = false;
|
||||
document.querySelectorAll("link").forEach((link) => {{
|
||||
if (link.getAttribute('href').includes(msg.css)) {{
|
||||
let newHref = '/' + msg.css + '?version=' + new Date().getMilliseconds();
|
||||
link.setAttribute('href', newHref);
|
||||
found = true;
|
||||
}}
|
||||
}});
|
||||
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
|
||||
}};
|
||||
}};
|
||||
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
|
||||
}})()
|
||||
</script>
|
||||
"#
|
||||
),
|
||||
false => "".to_string(),
|
||||
};
|
||||
|
||||
let html_metadata = meta_context
|
||||
.and_then(|mc| mc.html.as_string())
|
||||
.unwrap_or_default();
|
||||
let head = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html{html_metadata}>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
|
||||
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
|
||||
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
|
||||
{leptos_autoreload}
|
||||
"#
|
||||
);
|
||||
let tail = "</body></html>".to_string();
|
||||
|
||||
(head, tail)
|
||||
// Set status to what is returned in the function
|
||||
let res_status = res.status_mut();
|
||||
*res_status = status;
|
||||
// Return the response
|
||||
res
|
||||
}
|
||||
|
||||
/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically
|
||||
@@ -633,7 +844,7 @@ fn html_parts(
|
||||
/// as an argument so it can walk you app tree. This version is tailored to generated Actix compatible paths.
|
||||
pub fn generate_route_list<IV>(
|
||||
app_fn: impl FnOnce(leptos::Scope) -> IV + 'static,
|
||||
) -> Vec<String>
|
||||
) -> Vec<(String, SsrMode)>
|
||||
where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
@@ -641,12 +852,12 @@ where
|
||||
|
||||
// Empty strings screw with Actix pathing, they need to be "/"
|
||||
routes = routes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
.into_iter()
|
||||
.map(|(s, mode)| {
|
||||
if s.is_empty() {
|
||||
return "/".to_string();
|
||||
return ("/".to_string(), mode);
|
||||
}
|
||||
s.to_string()
|
||||
(s, mode)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -656,14 +867,14 @@ where
|
||||
// Match `:some_word` but only capture `some_word` in the groups to replace with `{some_word}`
|
||||
let capture_re = Regex::new(r":((?:[^.,/]+)+)[^/]?").unwrap();
|
||||
|
||||
let routes: Vec<String> = routes
|
||||
.iter()
|
||||
.map(|s| wildcard_re.replace_all(s, "{tail:.*}").to_string())
|
||||
.map(|s| capture_re.replace_all(&s, "{$1}").to_string())
|
||||
let routes: Vec<(String, SsrMode)> = routes
|
||||
.into_iter()
|
||||
.map(|(s, m)| (wildcard_re.replace_all(&s, "{tail:.*}").to_string(), m))
|
||||
.map(|(s, m)| (capture_re.replace_all(&s, "{$1}").to_string(), m))
|
||||
.collect();
|
||||
|
||||
if routes.is_empty() {
|
||||
vec!["/".to_string()]
|
||||
vec![("/".to_string(), Default::default())]
|
||||
} else {
|
||||
routes
|
||||
}
|
||||
@@ -674,18 +885,22 @@ pub enum DataResponse<T> {
|
||||
Response(actix_web::dev::Response<BoxBody>),
|
||||
}
|
||||
|
||||
/// This trait allows one to pass a list of routes and a render function to Axum's router, letting us avoid
|
||||
/// This trait allows one to pass a list of routes and a render function to Actix's router, letting us avoid
|
||||
/// having to use wildcards or manually define all routes in multiple places.
|
||||
pub trait LeptosRoutes {
|
||||
fn leptos_routes<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
where
|
||||
IV: IntoView + 'static;
|
||||
|
||||
#[deprecated = "You can now use `leptos_routes` and a `<Route \
|
||||
mode=SsrMode::Async/>`
|
||||
to achieve async rendering without manually preloading \
|
||||
data."]
|
||||
fn leptos_preloaded_data_routes<Data, Fut, IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
@@ -701,7 +916,7 @@ pub trait LeptosRoutes {
|
||||
fn leptos_routes_with_context<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
@@ -723,20 +938,13 @@ where
|
||||
fn leptos_routes<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
let mut router = self;
|
||||
for path in paths.iter() {
|
||||
router = router.route(
|
||||
path,
|
||||
render_app_to_stream(options.clone(), app_fn.clone()),
|
||||
);
|
||||
}
|
||||
router
|
||||
self.leptos_routes_with_context(options, paths, |_| {}, app_fn)
|
||||
}
|
||||
|
||||
fn leptos_preloaded_data_routes<Data, Fut, IV>(
|
||||
@@ -756,6 +964,7 @@ where
|
||||
for path in paths.iter() {
|
||||
router = router.route(
|
||||
path,
|
||||
#[allow(deprecated)]
|
||||
render_preloaded_data_app(
|
||||
options.clone(),
|
||||
data_fn.clone(),
|
||||
@@ -769,7 +978,7 @@ where
|
||||
fn leptos_routes_with_context<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
@@ -777,14 +986,28 @@ where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
let mut router = self;
|
||||
for path in paths.iter() {
|
||||
for (path, mode) in paths.iter() {
|
||||
router = router.route(
|
||||
path,
|
||||
render_app_to_stream_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
),
|
||||
match mode {
|
||||
SsrMode::OutOfOrder => render_app_to_stream_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
),
|
||||
SsrMode::InOrder => {
|
||||
render_app_to_stream_in_order_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
)
|
||||
}
|
||||
SsrMode::Async => render_app_async_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
router
|
||||
|
||||
@@ -16,5 +16,6 @@ leptos = { workspace = true, features = ["ssr"] }
|
||||
leptos_meta = { workspace = true, features = ["ssr"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_config = { workspace = true }
|
||||
leptos_integration_utils = { workspace = true }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
parking_lot = "0.12.1"
|
||||
|
||||
@@ -16,14 +16,19 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use futures::{Future, SinkExt, Stream, StreamExt};
|
||||
use futures::{
|
||||
channel::mpsc::{Receiver, Sender},
|
||||
Future, SinkExt, Stream, StreamExt,
|
||||
};
|
||||
use http::{header, method::Method, uri::Uri, version::Version, Response};
|
||||
use hyper::body;
|
||||
use leptos::{
|
||||
leptos_server::{server_fn_by_path, Payload},
|
||||
ssr::*,
|
||||
*,
|
||||
};
|
||||
use leptos_meta::MetaContext;
|
||||
use leptos_integration_utils::{build_async_response, html_parts};
|
||||
use leptos_meta::{generate_head_metadata, MetaContext};
|
||||
use leptos_router::*;
|
||||
use parking_lot::RwLock;
|
||||
use std::{io, pin::Pin, sync::Arc};
|
||||
@@ -401,6 +406,79 @@ where
|
||||
render_app_to_stream_with_context(options, |_| {}, app_fn)
|
||||
}
|
||||
|
||||
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an in-order HTML stream of your application.
|
||||
/// This stream will pause at each `<Suspense/>` node and wait for it to resolve befores
|
||||
/// sending down its HTML. The app will become interactive once it has fully loaded.
|
||||
///
|
||||
/// The provides a [MetaContext] and a [RouterIntegrationContext] to app’s context before
|
||||
/// rendering it, and includes any meta tags injected using [leptos_meta].
|
||||
///
|
||||
/// The HTML stream is rendered using [render_to_stream_in_order], and includes everything described in
|
||||
/// the documentation for that function.
|
||||
///
|
||||
/// This can then be set up at an appropriate route in your application:
|
||||
/// ```
|
||||
/// use axum::{handler::Handler, Router};
|
||||
/// use leptos::*;
|
||||
/// use leptos_config::get_configuration;
|
||||
/// use std::{env, net::SocketAddr};
|
||||
///
|
||||
/// #[component]
|
||||
/// fn MyApp(cx: Scope) -> impl IntoView {
|
||||
/// view! { cx, <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # if false { // don't actually try to run a server in a doctest...
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
||||
/// let leptos_options = conf.leptos_options;
|
||||
/// let addr = leptos_options.site_addr.clone();
|
||||
///
|
||||
/// // build our application with a route
|
||||
/// let app =
|
||||
/// Router::new().fallback(leptos_axum::render_app_to_stream_in_order(
|
||||
/// leptos_options,
|
||||
/// |cx| view! { cx, <MyApp/> },
|
||||
/// ));
|
||||
///
|
||||
/// // run our app with hyper
|
||||
/// // `axum::Server` is a re-export of `hyper::Server`
|
||||
/// axum::Server::bind(&addr)
|
||||
/// .serve(app.into_make_service())
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [RequestParts]
|
||||
/// - [ResponseOptions]
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_to_stream_in_order<IV>(
|
||||
options: LeptosOptions,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> impl Fn(
|
||||
Request<Body>,
|
||||
) -> Pin<
|
||||
Box<
|
||||
dyn Future<Output = Response<StreamBody<PinnedHtmlStream>>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>,
|
||||
> + Clone
|
||||
+ Send
|
||||
+ 'static
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
render_app_to_stream_in_order_with_context(options, |_| {}, app_fn)
|
||||
}
|
||||
|
||||
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an HTML stream of your application.
|
||||
///
|
||||
@@ -461,7 +539,7 @@ where
|
||||
|
||||
let full_path = format!("http://leptos.dev{path}");
|
||||
|
||||
let (mut tx, rx) = futures::channel::mpsc::channel(8);
|
||||
let (tx, rx) = futures::channel::mpsc::channel(8);
|
||||
|
||||
spawn_blocking({
|
||||
let app_fn = app_fn.clone();
|
||||
@@ -479,17 +557,7 @@ where
|
||||
let full_path = full_path.clone();
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
move |cx| {
|
||||
let integration = ServerIntegration {
|
||||
path: full_path.clone(),
|
||||
};
|
||||
provide_context(
|
||||
cx,
|
||||
RouterIntegrationContext::new(integration),
|
||||
);
|
||||
provide_context(cx, MetaContext::new());
|
||||
provide_context(cx, req_parts);
|
||||
provide_context(cx, default_res_options);
|
||||
provide_server_redirect(cx, move |path| redirect(cx, path));
|
||||
provide_contexts(cx, full_path, req_parts, default_res_options);
|
||||
app_fn(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
@@ -497,37 +565,11 @@ where
|
||||
let (bundle, runtime, scope) =
|
||||
leptos::leptos_dom::ssr::render_to_stream_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
|cx| {
|
||||
let head = use_context::<MetaContext>(cx)
|
||||
.map(|meta| meta.dehydrate())
|
||||
.unwrap_or_default();
|
||||
format!("{head}</head><body>").into()
|
||||
},
|
||||
|cx| generate_head_metadata(cx).into(),
|
||||
add_context,
|
||||
);
|
||||
|
||||
let cx = Scope { runtime, id: scope };
|
||||
let (head, tail) = html_parts(&options, use_context::<MetaContext>(cx).as_ref());
|
||||
|
||||
_ = tx.send(head).await;
|
||||
let mut shell = Box::pin(bundle);
|
||||
while let Some(fragment) = shell.next().await {
|
||||
_ = tx.send(fragment).await;
|
||||
}
|
||||
_ = tx.send(tail.to_string()).await;
|
||||
|
||||
// Extract the value of ResponseOptions from here
|
||||
let res_options =
|
||||
use_context::<ResponseOptions>(cx).unwrap();
|
||||
|
||||
let new_res_parts = res_options.0.read().clone();
|
||||
|
||||
let mut writable = res_options2.0.write();
|
||||
*writable = new_res_parts;
|
||||
|
||||
runtime.dispose();
|
||||
|
||||
tx.close_channel();
|
||||
forward_stream(&options, res_options2, bundle, runtime, scope, tx).await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -535,26 +577,372 @@ where
|
||||
}
|
||||
});
|
||||
|
||||
let mut stream = Box::pin(rx.map(|html| Ok(Bytes::from(html))));
|
||||
generate_response(res_options3, rx).await
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get the first and second chunks in the stream, which renders the app shell, and thus allows Resources to run
|
||||
let first_chunk = stream.next().await;
|
||||
let second_chunk = stream.next().await;
|
||||
async fn generate_response(
|
||||
res_options: ResponseOptions,
|
||||
rx: Receiver<String>,
|
||||
) -> Response<StreamBody<PinnedHtmlStream>> {
|
||||
let mut stream = Box::pin(rx.map(|html| Ok(Bytes::from(html))));
|
||||
|
||||
// Get the first and second chunks in the stream, which renders the app shell, and thus allows Resources to run
|
||||
let first_chunk = stream.next().await;
|
||||
let second_chunk = stream.next().await;
|
||||
|
||||
// Extract the resources now that they've been rendered
|
||||
let res_options = res_options.0.read();
|
||||
|
||||
let complete_stream =
|
||||
futures::stream::iter([first_chunk.unwrap(), second_chunk.unwrap()])
|
||||
.chain(stream);
|
||||
|
||||
let mut res = Response::new(StreamBody::new(
|
||||
Box::pin(complete_stream) as PinnedHtmlStream
|
||||
));
|
||||
|
||||
if let Some(status) = res_options.status {
|
||||
*res.status_mut() = status
|
||||
}
|
||||
let mut res_headers = res_options.headers.clone();
|
||||
res.headers_mut().extend(res_headers.drain());
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
async fn forward_stream(
|
||||
options: &LeptosOptions,
|
||||
res_options2: ResponseOptions,
|
||||
bundle: impl Stream<Item = String> + 'static,
|
||||
runtime: RuntimeId,
|
||||
scope: ScopeId,
|
||||
mut tx: Sender<String>,
|
||||
) {
|
||||
let cx = Scope { runtime, id: scope };
|
||||
let (head, tail) =
|
||||
html_parts(options, use_context::<MetaContext>(cx).as_ref());
|
||||
|
||||
_ = tx.send(head).await;
|
||||
let mut shell = Box::pin(bundle);
|
||||
while let Some(fragment) = shell.next().await {
|
||||
_ = tx.send(fragment).await;
|
||||
}
|
||||
_ = tx.send(tail.to_string()).await;
|
||||
|
||||
// Extract the value of ResponseOptions from here
|
||||
let res_options = use_context::<ResponseOptions>(cx).unwrap();
|
||||
|
||||
let new_res_parts = res_options.0.read().clone();
|
||||
|
||||
let mut writable = res_options2.0.write();
|
||||
*writable = new_res_parts;
|
||||
|
||||
runtime.dispose();
|
||||
|
||||
tx.close_channel();
|
||||
}
|
||||
|
||||
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], serving an in-order HTML stream of your application.
|
||||
/// This stream will pause at each `<Suspense/>` node and wait for it to resolve befores
|
||||
/// sending down its HTML. The app will become interactive once it has fully loaded.
|
||||
///
|
||||
/// This version allows us to pass Axum State/Extension/Extractor or other infro from Axum or network
|
||||
/// layers above Leptos itself. To use it, you'll need to write your own handler function that provides
|
||||
/// the data to leptos in a closure. An example is below
|
||||
/// ```ignore
|
||||
/// async fn custom_handler(Path(id): Path<String>, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> Response{
|
||||
/// let handler = leptos_axum::render_app_to_stream_in_order_with_context((*options).clone(),
|
||||
/// move |cx| {
|
||||
/// provide_context(cx, id.clone());
|
||||
/// },
|
||||
/// |cx| view! { cx, <TodoApp/> }
|
||||
/// );
|
||||
/// handler(req).await.into_response()
|
||||
/// }
|
||||
/// ```
|
||||
/// Otherwise, this function is identical to [render_app_to_stream].
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [RequestParts]
|
||||
/// - [ResponseOptions]
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_to_stream_in_order_with_context<IV>(
|
||||
options: LeptosOptions,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> impl Fn(
|
||||
Request<Body>,
|
||||
) -> Pin<
|
||||
Box<
|
||||
dyn Future<Output = Response<StreamBody<PinnedHtmlStream>>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>,
|
||||
> + Clone
|
||||
+ Send
|
||||
+ 'static
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
move |req: Request<Body>| {
|
||||
Box::pin({
|
||||
let options = options.clone();
|
||||
let app_fn = app_fn.clone();
|
||||
let add_context = additional_context.clone();
|
||||
let default_res_options = ResponseOptions::default();
|
||||
let res_options2 = default_res_options.clone();
|
||||
let res_options3 = default_res_options.clone();
|
||||
|
||||
async move {
|
||||
// Need to get the path and query string of the Request
|
||||
// For reasons that escape me, if the incoming URI protocol is https, it provides the absolute URI
|
||||
// if http, it returns a relative path. Adding .path() seems to make it explicitly return the relative uri
|
||||
let path = req.uri().path_and_query().unwrap().as_str();
|
||||
|
||||
let full_path = format!("http://leptos.dev{path}");
|
||||
|
||||
let (tx, rx) = futures::channel::mpsc::channel(8);
|
||||
|
||||
spawn_blocking({
|
||||
let app_fn = app_fn.clone();
|
||||
let add_context = add_context.clone();
|
||||
move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.expect("couldn't spawn runtime")
|
||||
.block_on({
|
||||
let app_fn = app_fn.clone();
|
||||
let add_context = add_context.clone();
|
||||
async move {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let app = {
|
||||
let full_path = full_path.clone();
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
move |cx| {
|
||||
provide_contexts(cx, full_path, req_parts, default_res_options);
|
||||
app_fn(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
|
||||
let (bundle, runtime, scope) =
|
||||
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
|cx| generate_head_metadata(cx).into(),
|
||||
add_context,
|
||||
);
|
||||
|
||||
forward_stream(&options, res_options2, bundle, runtime, scope, tx).await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
generate_response(res_options3, rx).await
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn provide_contexts(
|
||||
cx: Scope,
|
||||
path: String,
|
||||
req_parts: RequestParts,
|
||||
default_res_options: ResponseOptions,
|
||||
) {
|
||||
let integration = ServerIntegration { path };
|
||||
provide_context(cx, RouterIntegrationContext::new(integration));
|
||||
provide_context(cx, MetaContext::new());
|
||||
provide_context(cx, req_parts);
|
||||
provide_context(cx, default_res_options);
|
||||
provide_server_redirect(cx, move |path| redirect(cx, path));
|
||||
}
|
||||
|
||||
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
|
||||
/// `async` [Resource](leptos::Resource)s have loaded.
|
||||
///
|
||||
/// The provides a [MetaContext] and a [RouterIntegrationContext] to app’s context before
|
||||
/// rendering it, and includes any meta tags injected using [leptos_meta].
|
||||
///
|
||||
/// The HTML stream is rendered using [render_to_string_async], and includes everything described in
|
||||
/// the documentation for that function.
|
||||
///
|
||||
/// This can then be set up at an appropriate route in your application:
|
||||
/// ```
|
||||
/// use axum::{handler::Handler, Router};
|
||||
/// use leptos::*;
|
||||
/// use leptos_config::get_configuration;
|
||||
/// use std::{env, net::SocketAddr};
|
||||
///
|
||||
/// #[component]
|
||||
/// fn MyApp(cx: Scope) -> impl IntoView {
|
||||
/// view! { cx, <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # if false { // don't actually try to run a server in a doctest...
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
||||
/// let leptos_options = conf.leptos_options;
|
||||
/// let addr = leptos_options.site_addr.clone();
|
||||
///
|
||||
/// // build our application with a route
|
||||
/// let app = Router::new().fallback(leptos_axum::render_app_async(
|
||||
/// leptos_options,
|
||||
/// |cx| view! { cx, <MyApp/> },
|
||||
/// ));
|
||||
///
|
||||
/// // run our app with hyper
|
||||
/// // `axum::Server` is a re-export of `hyper::Server`
|
||||
/// axum::Server::bind(&addr)
|
||||
/// .serve(app.into_make_service())
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [RequestParts]
|
||||
/// - [ResponseOptions]
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_async<IV>(
|
||||
options: LeptosOptions,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> impl Fn(
|
||||
Request<Body>,
|
||||
) -> Pin<Box<dyn Future<Output = Response<String>> + Send + 'static>>
|
||||
+ Clone
|
||||
+ Send
|
||||
+ 'static
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
render_app_async_with_context(options, |_| {}, app_fn)
|
||||
}
|
||||
|
||||
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
|
||||
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
|
||||
/// `async` [Resource](leptos::Resource)s have loaded.
|
||||
///
|
||||
/// This version allows us to pass Axum State/Extension/Extractor or other infro from Axum or network
|
||||
/// layers above Leptos itself. To use it, you'll need to write your own handler function that provides
|
||||
/// the data to leptos in a closure. An example is below
|
||||
/// ```ignore
|
||||
/// async fn custom_handler(Path(id): Path<String>, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> Response{
|
||||
/// let handler = leptos_axum::render_app_async_with_context((*options).clone(),
|
||||
/// move |cx| {
|
||||
/// provide_context(cx, id.clone());
|
||||
/// },
|
||||
/// |cx| view! { cx, <TodoApp/> }
|
||||
/// );
|
||||
/// handler(req).await.into_response()
|
||||
/// }
|
||||
/// ```
|
||||
/// Otherwise, this function is identical to [render_app_to_stream].
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
/// This function always provides context values including the following types:
|
||||
/// - [RequestParts]
|
||||
/// - [ResponseOptions]
|
||||
/// - [MetaContext](leptos_meta::MetaContext)
|
||||
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
|
||||
pub fn render_app_async_with_context<IV>(
|
||||
options: LeptosOptions,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> impl Fn(
|
||||
Request<Body>,
|
||||
) -> Pin<Box<dyn Future<Output = Response<String>> + Send + 'static>>
|
||||
+ Clone
|
||||
+ Send
|
||||
+ 'static
|
||||
where
|
||||
IV: IntoView,
|
||||
{
|
||||
move |req: Request<Body>| {
|
||||
Box::pin({
|
||||
let options = options.clone();
|
||||
let app_fn = app_fn.clone();
|
||||
let add_context = additional_context.clone();
|
||||
let default_res_options = ResponseOptions::default();
|
||||
let res_options2 = default_res_options.clone();
|
||||
let res_options3 = default_res_options.clone();
|
||||
|
||||
async move {
|
||||
// Need to get the path and query string of the Request
|
||||
// For reasons that escape me, if the incoming URI protocol is https, it provides the absolute URI
|
||||
// if http, it returns a relative path. Adding .path() seems to make it explicitly return the relative uri
|
||||
let path = req.uri().path_and_query().unwrap().as_str();
|
||||
|
||||
let full_path = format!("http://leptos.dev{path}");
|
||||
|
||||
let (tx, rx) = futures::channel::oneshot::channel();
|
||||
|
||||
spawn_blocking({
|
||||
let app_fn = app_fn.clone();
|
||||
let add_context = add_context.clone();
|
||||
move || {
|
||||
tokio::runtime::Runtime::new()
|
||||
.expect("couldn't spawn runtime")
|
||||
.block_on({
|
||||
let app_fn = app_fn.clone();
|
||||
let add_context = add_context.clone();
|
||||
async move {
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let app = {
|
||||
let full_path = full_path.clone();
|
||||
let req_parts = generate_request_parts(req).await;
|
||||
move |cx| {
|
||||
provide_contexts(cx, full_path, req_parts, default_res_options);
|
||||
app_fn(cx).into_view(cx)
|
||||
}
|
||||
};
|
||||
|
||||
let (stream, runtime, scope) =
|
||||
render_to_stream_with_prefix_undisposed_with_context(
|
||||
app,
|
||||
|_| "".into(),
|
||||
add_context,
|
||||
);
|
||||
|
||||
// Extract the value of ResponseOptions from here
|
||||
let cx = leptos::Scope { runtime, id: scope };
|
||||
let res_options =
|
||||
use_context::<ResponseOptions>(cx).unwrap();
|
||||
|
||||
let html = build_async_response(stream, &options, runtime, scope).await;
|
||||
|
||||
let new_res_parts = res_options.0.read().clone();
|
||||
|
||||
let mut writable = res_options2.0.write();
|
||||
*writable = new_res_parts;
|
||||
|
||||
_ = tx.send(html);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let html = rx.await.expect("to complete HTML rendering");
|
||||
|
||||
let mut res = Response::new(html);
|
||||
|
||||
// Extract the resources now that they've been rendered
|
||||
let res_options = res_options3.0.read();
|
||||
|
||||
let complete_stream = futures::stream::iter([
|
||||
first_chunk.unwrap(),
|
||||
second_chunk.unwrap(),
|
||||
])
|
||||
.chain(stream);
|
||||
|
||||
let mut res = Response::new(StreamBody::new(Box::pin(
|
||||
complete_stream,
|
||||
)
|
||||
as PinnedHtmlStream));
|
||||
|
||||
if let Some(status) = res_options.status {
|
||||
*res.status_mut() = status
|
||||
}
|
||||
@@ -567,81 +955,17 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn html_parts(
|
||||
options: &LeptosOptions,
|
||||
meta: Option<&MetaContext>,
|
||||
) -> (String, &'static str) {
|
||||
let pkg_path = &options.site_pkg_dir;
|
||||
let output_name = &options.output_name;
|
||||
|
||||
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
|
||||
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
|
||||
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
|
||||
let mut wasm_output_name = output_name.clone();
|
||||
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
|
||||
wasm_output_name.push_str("_bg");
|
||||
}
|
||||
|
||||
let site_ip = &options.site_addr.ip().to_string();
|
||||
let reload_port = options.reload_port;
|
||||
|
||||
let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() {
|
||||
true => format!(
|
||||
r#"
|
||||
<script crossorigin="">(function () {{
|
||||
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
ws.onmessage = (ev) => {{
|
||||
let msg = JSON.parse(ev.data);
|
||||
if (msg.all) window.location.reload();
|
||||
if (msg.css) {{
|
||||
let found = false;
|
||||
document.querySelectorAll("link").forEach((link) => {{
|
||||
if (link.getAttribute('href').includes(msg.css)) {{
|
||||
let newHref = '/' + href + '?version=' + new Date().getMilliseconds();
|
||||
link.setAttribute('href', newHref);
|
||||
found = true;
|
||||
}}
|
||||
}});
|
||||
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
|
||||
}};
|
||||
}};
|
||||
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
|
||||
}})()
|
||||
</script>
|
||||
"#
|
||||
),
|
||||
false => "".to_string(),
|
||||
};
|
||||
|
||||
let html_metadata =
|
||||
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
|
||||
let head = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html{html_metadata}>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
|
||||
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
|
||||
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
|
||||
{leptos_autoreload}
|
||||
"#
|
||||
);
|
||||
let tail = "</body></html>";
|
||||
(head, tail)
|
||||
}
|
||||
|
||||
/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically
|
||||
/// create routes in Axum's Router without having to use wildcard matching or fallbacks. Takes in your root app Element
|
||||
/// as an argument so it can walk you app tree. This version is tailored to generate Axum compatible paths.
|
||||
pub async fn generate_route_list<IV>(
|
||||
app_fn: impl FnOnce(Scope) -> IV + 'static,
|
||||
) -> Vec<String>
|
||||
) -> Vec<(String, SsrMode)>
|
||||
where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct Routes(pub Arc<RwLock<Vec<String>>>);
|
||||
pub struct Routes(pub Arc<RwLock<Vec<(String, SsrMode)>>>);
|
||||
|
||||
let routes = Routes::default();
|
||||
let routes_inner = routes.clone();
|
||||
@@ -663,13 +987,19 @@ where
|
||||
|
||||
let routes = routes.0.read().to_owned();
|
||||
// Axum's Router defines Root routes as "/" not ""
|
||||
let routes: Vec<String> = routes
|
||||
let routes = routes
|
||||
.into_iter()
|
||||
.map(|s| if s.is_empty() { "/".to_string() } else { s })
|
||||
.collect();
|
||||
.map(|(s, m)| {
|
||||
if s.is_empty() {
|
||||
("/".to_string(), m)
|
||||
} else {
|
||||
(s, m)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if routes.is_empty() {
|
||||
vec!["/".to_string()]
|
||||
vec![("/".to_string(), Default::default())]
|
||||
} else {
|
||||
routes
|
||||
}
|
||||
@@ -681,7 +1011,7 @@ pub trait LeptosRoutes {
|
||||
fn leptos_routes<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
where
|
||||
@@ -690,7 +1020,7 @@ pub trait LeptosRoutes {
|
||||
fn leptos_routes_with_context<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
@@ -699,7 +1029,7 @@ pub trait LeptosRoutes {
|
||||
|
||||
fn leptos_routes_with_handler<H, T>(
|
||||
self,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
handler: H,
|
||||
) -> Self
|
||||
where
|
||||
@@ -712,26 +1042,19 @@ impl LeptosRoutes for axum::Router {
|
||||
fn leptos_routes<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
let mut router = self;
|
||||
for path in paths.iter() {
|
||||
router = router.route(
|
||||
path,
|
||||
get(render_app_to_stream(options.clone(), app_fn.clone())),
|
||||
);
|
||||
}
|
||||
router
|
||||
self.leptos_routes_with_context(options, paths, |_| {}, app_fn)
|
||||
}
|
||||
|
||||
fn leptos_routes_with_context<IV>(
|
||||
self,
|
||||
options: LeptosOptions,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
|
||||
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
|
||||
) -> Self
|
||||
@@ -739,14 +1062,30 @@ impl LeptosRoutes for axum::Router {
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
let mut router = self;
|
||||
for path in paths.iter() {
|
||||
for (path, mode) in paths.iter() {
|
||||
router = router.route(
|
||||
path,
|
||||
get(render_app_to_stream_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
)),
|
||||
match mode {
|
||||
SsrMode::OutOfOrder => {
|
||||
get(render_app_to_stream_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
))
|
||||
}
|
||||
SsrMode::InOrder => {
|
||||
get(render_app_to_stream_in_order_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
))
|
||||
}
|
||||
SsrMode::Async => get(render_app_async_with_context(
|
||||
options.clone(),
|
||||
additional_context.clone(),
|
||||
app_fn.clone(),
|
||||
)),
|
||||
},
|
||||
);
|
||||
}
|
||||
router
|
||||
@@ -754,7 +1093,7 @@ impl LeptosRoutes for axum::Router {
|
||||
|
||||
fn leptos_routes_with_handler<H, T>(
|
||||
self,
|
||||
paths: Vec<String>,
|
||||
paths: Vec<(String, SsrMode)>,
|
||||
handler: H,
|
||||
) -> Self
|
||||
where
|
||||
@@ -762,7 +1101,7 @@ impl LeptosRoutes for axum::Router {
|
||||
T: 'static,
|
||||
{
|
||||
let mut router = self;
|
||||
for path in paths.iter() {
|
||||
for (path, _) in paths.iter() {
|
||||
router = router.route(path, get(handler.clone()));
|
||||
}
|
||||
router
|
||||
|
||||
11
integrations/utils/Cargo.toml
Normal file
11
integrations/utils/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "leptos_integration_utils"
|
||||
version = { workspace = true }
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
futures = "0.3"
|
||||
leptos = { workspace = true, features = ["ssr"] }
|
||||
leptos_meta = { workspace = true, features = ["ssr"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_config = { workspace = true }
|
||||
100
integrations/utils/src/lib.rs
Normal file
100
integrations/utils/src/lib.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use futures::{Stream, StreamExt};
|
||||
use leptos::{use_context, RuntimeId, ScopeId};
|
||||
use leptos_config::LeptosOptions;
|
||||
use leptos_meta::MetaContext;
|
||||
|
||||
pub fn html_parts(
|
||||
options: &LeptosOptions,
|
||||
meta: Option<&MetaContext>,
|
||||
) -> (String, &'static str) {
|
||||
let pkg_path = &options.site_pkg_dir;
|
||||
let output_name = &options.output_name;
|
||||
|
||||
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
|
||||
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
|
||||
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
|
||||
let mut wasm_output_name = output_name.clone();
|
||||
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
|
||||
wasm_output_name.push_str("_bg");
|
||||
}
|
||||
|
||||
let site_ip = &options.site_addr.ip().to_string();
|
||||
let reload_port = options.reload_port;
|
||||
|
||||
let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() {
|
||||
true => format!(
|
||||
r#"
|
||||
<script crossorigin="">(function () {{
|
||||
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
|
||||
ws.onmessage = (ev) => {{
|
||||
let msg = JSON.parse(ev.data);
|
||||
if (msg.all) window.location.reload();
|
||||
if (msg.css) {{
|
||||
let found = false;
|
||||
document.querySelectorAll("link").forEach((link) => {{
|
||||
if (link.getAttribute('href').includes(msg.css)) {{
|
||||
let newHref = '/' + msg.css + '?version=' + new Date().getMilliseconds();
|
||||
link.setAttribute('href', newHref);
|
||||
found = true;
|
||||
}}
|
||||
}});
|
||||
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
|
||||
}};
|
||||
}};
|
||||
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
|
||||
}})()
|
||||
</script>
|
||||
"#
|
||||
),
|
||||
false => "".to_string(),
|
||||
};
|
||||
|
||||
let html_metadata =
|
||||
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
|
||||
let head = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html{html_metadata}>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
|
||||
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
|
||||
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
|
||||
{leptos_autoreload}
|
||||
"#
|
||||
);
|
||||
let tail = "</body></html>";
|
||||
(head, tail)
|
||||
}
|
||||
|
||||
pub async fn build_async_response(
|
||||
stream: impl Stream<Item = String> + 'static,
|
||||
options: &LeptosOptions,
|
||||
runtime: RuntimeId,
|
||||
scope: ScopeId,
|
||||
) -> String {
|
||||
let mut buf = String::new();
|
||||
let mut stream = Box::pin(stream);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
buf.push_str(&chunk);
|
||||
}
|
||||
|
||||
let cx = leptos::Scope { runtime, id: scope };
|
||||
let (head, tail) =
|
||||
html_parts(options, use_context::<MetaContext>(cx).as_ref());
|
||||
|
||||
// in async, we load the meta content *now*, after the suspenses have resolved
|
||||
let meta = use_context::<MetaContext>(cx);
|
||||
let head_meta = 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();
|
||||
|
||||
runtime.dispose();
|
||||
|
||||
format!("{head}{head_meta}</head><body{body_meta}>{buf}{tail}")
|
||||
}
|
||||
@@ -146,7 +146,10 @@ pub use leptos_config::{self, get_configuration, LeptosOptions};
|
||||
target_arch = "wasm32",
|
||||
any(feature = "csr", feature = "hydrate")
|
||||
)))]
|
||||
pub use leptos_dom::ssr::{self, render_to_string};
|
||||
/// Utilities for server-side rendering HTML.
|
||||
pub mod ssr {
|
||||
pub use leptos_dom::{ssr::*, ssr_in_order::*};
|
||||
}
|
||||
pub use leptos_dom::{
|
||||
self, create_node_ref, debug_warn, document, error, ev,
|
||||
helpers::{
|
||||
|
||||
@@ -62,8 +62,6 @@ where
|
||||
F: Fn() -> E + 'static,
|
||||
E: IntoView,
|
||||
{
|
||||
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
|
||||
let id_before_suspense = HydrationCtx::peek();
|
||||
let context = SuspenseContext::new(cx);
|
||||
|
||||
// provide this SuspenseContext to any resources below it
|
||||
@@ -100,10 +98,11 @@ where
|
||||
|
||||
cx.register_suspense(
|
||||
context,
|
||||
&id_before_suspense.to_string(),
|
||||
¤t_id.to_string(),
|
||||
// out-of-order streaming
|
||||
{
|
||||
let current_id = current_id.clone();
|
||||
let orig_child = Rc::clone(&orig_child);
|
||||
move || {
|
||||
HydrationCtx::continue_from(current_id.clone());
|
||||
DynChild::new(move || orig_child(cx))
|
||||
@@ -111,6 +110,16 @@ where
|
||||
.render_to_string(cx)
|
||||
.to_string()
|
||||
}
|
||||
},
|
||||
// in-order streaming
|
||||
{
|
||||
let current_id = current_id.clone();
|
||||
move || {
|
||||
HydrationCtx::continue_from(current_id.clone());
|
||||
DynChild::new(move || orig_child(cx))
|
||||
.into_view(cx)
|
||||
.into_stream_chunks(cx)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ fn ssr_test_with_components() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" \
|
||||
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><div \
|
||||
"<div class=\"counters\" \
|
||||
id=\"_0-1\"><!--hk=_0-1-0o|leptos-counter-start--><div \
|
||||
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
|
||||
id=\"_0-1-3\">Value: \
|
||||
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
|
||||
@@ -102,8 +102,8 @@ fn ssr_test_with_snake_case_components() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" \
|
||||
class=\"counters\"><!\
|
||||
"<div class=\"counters\" \
|
||||
id=\"_0-1\"><!\
|
||||
--hk=_0-1-0o|leptos-snake-case-counter-start--><div \
|
||||
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
|
||||
id=\"_0-1-3\">Value: \
|
||||
@@ -136,7 +136,7 @@ fn test_classes() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" class=\"my big red car\"></div>"
|
||||
"<div class=\"my big red car\" id=\"_0-1\"></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -158,8 +158,8 @@ fn ssr_with_styles() {
|
||||
|
||||
assert_eq!(
|
||||
rendered.into_view(cx).render_to_string(cx),
|
||||
"<div id=\"_0-1\" class=\" myclass\"><button id=\"_0-2\" \
|
||||
class=\"btn myclass\">-1</button></div>"
|
||||
"<div class=\"myclass\" id=\"_0-1\"><button class=\"btn myclass\" \
|
||||
id=\"_0-2\">-1</button></div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ repository = "https://github.com/leptos-rs/leptos"
|
||||
description = "DOM operations for the Leptos web framework."
|
||||
|
||||
[dependencies]
|
||||
async-recursion = "1"
|
||||
cfg-if = "1"
|
||||
drain_filter_polyfill = "0.1"
|
||||
educe = "0.4"
|
||||
|
||||
@@ -138,11 +138,13 @@ where
|
||||
{
|
||||
/// Creates a new dynamic child which will re-render whenever it's
|
||||
/// signal dependencies change.
|
||||
#[track_caller]
|
||||
pub fn new(child_fn: CF) -> Self {
|
||||
Self::new_with_id(HydrationCtx::id(), child_fn)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[track_caller]
|
||||
pub fn new_with_id(id: HydrationKey, child_fn: CF) -> Self {
|
||||
Self { id, child_fn }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ mod macro_helpers;
|
||||
pub mod math;
|
||||
mod node_ref;
|
||||
pub mod ssr;
|
||||
pub mod ssr_in_order;
|
||||
pub mod svg;
|
||||
mod transparent;
|
||||
use cfg_if::cfg_if;
|
||||
@@ -122,6 +123,7 @@ where
|
||||
debug_assertions,
|
||||
instrument(level = "trace", name = "Fn() -> impl IntoView", skip_all)
|
||||
)]
|
||||
#[track_caller]
|
||||
fn into_view(self, cx: Scope) -> View {
|
||||
DynChild::new(self).into_view(cx)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
use crate::{CoreComponent, HydrationCtx, IntoView, View};
|
||||
use cfg_if::cfg_if;
|
||||
use futures::{stream::FuturesUnordered, Stream, StreamExt};
|
||||
use futures::{stream::FuturesUnordered, Future, Stream, StreamExt};
|
||||
use itertools::Itertools;
|
||||
use leptos_reactive::*;
|
||||
use std::borrow::Cow;
|
||||
use std::{borrow::Cow, pin::Pin};
|
||||
|
||||
type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
||||
|
||||
/// Renders the given function to a static HTML string.
|
||||
///
|
||||
@@ -154,14 +156,14 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
|
||||
});
|
||||
|
||||
let fragments = FuturesUnordered::new();
|
||||
for (fragment_id, (key_before, fut)) in pending_fragments {
|
||||
fragments.push(async move { (fragment_id, key_before, fut.await) })
|
||||
for (fragment_id, (fut, _)) in pending_fragments {
|
||||
fragments.push(async move { (fragment_id, fut.await) })
|
||||
}
|
||||
|
||||
// resources and fragments
|
||||
// stream HTML for each <Suspense/> as it resolves
|
||||
// TODO can remove id_before_suspense entirely now
|
||||
let fragments = fragments.map(|(fragment_id, _, html)| {
|
||||
let fragments = fragments.map(|(fragment_id, html)| {
|
||||
format!(
|
||||
r#"
|
||||
<template id="{fragment_id}f">{html}</template>
|
||||
@@ -188,18 +190,7 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
|
||||
)
|
||||
});
|
||||
// stream data for each Resource as it resolves
|
||||
let resources = serializers.map(|(id, json)| {
|
||||
let id = serde_json::to_string(&id).unwrap();
|
||||
format!(
|
||||
r#"<script>
|
||||
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
|
||||
__LEPTOS_RESOURCE_RESOLVERS.get({id})({json:?})
|
||||
}} else {{
|
||||
__LEPTOS_RESOLVED_RESOURCES.set({id}, {json:?});
|
||||
}}
|
||||
</script>"#,
|
||||
)
|
||||
});
|
||||
let resources = render_serializers(serializers);
|
||||
|
||||
// HTML for the view function and script to store resources
|
||||
let stream = futures::stream::once(async move {
|
||||
@@ -437,7 +428,7 @@ impl View {
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn to_kebab_case(name: &str) -> String {
|
||||
pub(crate) fn to_kebab_case(name: &str) -> String {
|
||||
if name.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
@@ -472,6 +463,23 @@ fn to_kebab_case(name: &str) -> String {
|
||||
new_name
|
||||
}
|
||||
|
||||
pub(crate) fn render_serializers(
|
||||
serializers: FuturesUnordered<PinnedFuture<(ResourceId, String)>>,
|
||||
) -> impl Stream<Item = String> {
|
||||
serializers.map(|(id, json)| {
|
||||
let id = serde_json::to_string(&id).unwrap();
|
||||
format!(
|
||||
r#"<script>
|
||||
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
|
||||
__LEPTOS_RESOURCE_RESOLVERS.get({id})({json:?})
|
||||
}} else {{
|
||||
__LEPTOS_RESOLVED_RESOURCES.set({id}, {json:?});
|
||||
}}
|
||||
</script>"#,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn escape_attr<T>(value: &T) -> Cow<'_, str>
|
||||
where
|
||||
|
||||
354
leptos_dom/src/ssr_in_order.rs
Normal file
354
leptos_dom/src/ssr_in_order.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
#![cfg(not(all(target_arch = "wasm32", feature = "web")))]
|
||||
|
||||
//! Server-side HTML rendering utilities for in-order streaming and async rendering.
|
||||
|
||||
use crate::{ssr::render_serializers, CoreComponent, HydrationCtx, View};
|
||||
use async_recursion::async_recursion;
|
||||
use cfg_if::cfg_if;
|
||||
use futures::{channel::mpsc::Sender, Stream, StreamExt};
|
||||
use itertools::Itertools;
|
||||
use leptos_reactive::{
|
||||
create_runtime, run_scope_undisposed, suspense::StreamChunk, RuntimeId,
|
||||
Scope, ScopeId,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Renders a view to HTML, waiting to return until all `async` [Resource](leptos_reactive::Resource)s
|
||||
/// loaded in `<Suspense/>` elements have finished loading.
|
||||
pub async fn render_to_string_async(
|
||||
view: impl FnOnce(Scope) -> View + 'static,
|
||||
) -> String {
|
||||
let mut buf = String::new();
|
||||
let mut stream = Box::pin(render_to_stream_in_order(view));
|
||||
while let Some(chunk) = stream.next().await {
|
||||
buf.push_str(&chunk);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Renders an in-order HTML stream, pausing at `<Suspense/>` components. The stream contains,
|
||||
/// in order:
|
||||
/// 1. HTML from the `view` in order, pausing to wait for each `<Suspense/>`
|
||||
/// 2. any serialized [Resource](leptos_reactive::Resource)s
|
||||
pub fn render_to_stream_in_order(
|
||||
view: impl FnOnce(Scope) -> View + 'static,
|
||||
) -> impl Stream<Item = String> {
|
||||
render_to_stream_in_order_with_prefix(view, |_| "".into())
|
||||
}
|
||||
|
||||
/// Renders an in-order HTML stream, pausing at `<Suspense/>` components. The stream contains,
|
||||
/// in order:
|
||||
/// 1. `prefix`
|
||||
/// 2. HTML from the `view` in order, pausing to wait for each `<Suspense/>`
|
||||
/// 3. any serialized [Resource](leptos_reactive::Resource)s
|
||||
///
|
||||
/// `additional_context` is injected before the `view` is rendered. The `prefix` is generated
|
||||
/// after the `view` is rendered, but before `<Suspense/>` nodes have resolved.
|
||||
pub fn render_to_stream_in_order_with_prefix(
|
||||
view: impl FnOnce(Scope) -> View + 'static,
|
||||
prefix: impl FnOnce(Scope) -> Cow<'static, str> + 'static,
|
||||
) -> impl Stream<Item = String> {
|
||||
let (stream, runtime, _) =
|
||||
render_to_stream_in_order_with_prefix_undisposed_with_context(
|
||||
view,
|
||||
prefix,
|
||||
|_| {},
|
||||
);
|
||||
runtime.dispose();
|
||||
stream
|
||||
}
|
||||
|
||||
/// Renders an in-order HTML stream, pausing at `<Suspense/>` components. The stream contains,
|
||||
/// in order:
|
||||
/// 1. `prefix`
|
||||
/// 2. HTML from the `view` in order, pausing to wait for each `<Suspense/>`
|
||||
/// 3. any serialized [Resource](leptos_reactive::Resource)s
|
||||
///
|
||||
/// `additional_context` is injected before the `view` is rendered. The `prefix` is generated
|
||||
/// after the `view` is rendered, but before `<Suspense/>` nodes have resolved.
|
||||
pub fn render_to_stream_in_order_with_prefix_undisposed_with_context(
|
||||
view: impl FnOnce(Scope) -> View + 'static,
|
||||
prefix: impl FnOnce(Scope) -> Cow<'static, str> + 'static,
|
||||
additional_context: impl FnOnce(Scope) + 'static,
|
||||
) -> (impl Stream<Item = String>, RuntimeId, ScopeId) {
|
||||
HydrationCtx::reset_id();
|
||||
|
||||
// create the runtime
|
||||
let runtime = create_runtime();
|
||||
|
||||
let ((chunks, prefix, pending_resources, serializers), scope_id, _) =
|
||||
run_scope_undisposed(runtime, |cx| {
|
||||
// add additional context
|
||||
additional_context(cx);
|
||||
|
||||
// render view and return chunks
|
||||
let view = view(cx);
|
||||
|
||||
let prefix = prefix(cx);
|
||||
(
|
||||
view.into_stream_chunks(cx),
|
||||
prefix,
|
||||
serde_json::to_string(&cx.pending_resources()).unwrap(),
|
||||
cx.serialization_resolvers(),
|
||||
)
|
||||
});
|
||||
|
||||
let (tx, rx) = futures::channel::mpsc::channel(1);
|
||||
leptos_reactive::spawn_local(async move {
|
||||
handle_chunks(tx, chunks).await;
|
||||
});
|
||||
|
||||
let stream = futures::stream::once(async move {
|
||||
format!(
|
||||
r#"
|
||||
{prefix}
|
||||
<script>
|
||||
__LEPTOS_PENDING_RESOURCES = {pending_resources};
|
||||
__LEPTOS_RESOLVED_RESOURCES = new Map();
|
||||
__LEPTOS_RESOURCE_RESOLVERS = new Map();
|
||||
</script>
|
||||
"#
|
||||
)
|
||||
})
|
||||
.chain(rx)
|
||||
.chain(render_serializers(serializers));
|
||||
|
||||
(stream, runtime, scope_id)
|
||||
}
|
||||
|
||||
#[async_recursion(?Send)]
|
||||
async fn handle_chunks(mut tx: Sender<String>, chunks: Vec<StreamChunk>) {
|
||||
let mut buffer = String::new();
|
||||
for chunk in chunks {
|
||||
match chunk {
|
||||
StreamChunk::Sync(sync) => buffer.push_str(&sync),
|
||||
StreamChunk::Async(suspended) => {
|
||||
// add static HTML before the Suspense and stream it down
|
||||
_ = tx.try_send(std::mem::take(&mut buffer));
|
||||
|
||||
// send the inner stream
|
||||
let suspended = suspended.await;
|
||||
handle_chunks(tx.clone(), suspended).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
// send final sync chunk
|
||||
_ = tx.try_send(std::mem::take(&mut buffer));
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// Renders the view into a set of HTML chunks that can be streamed.
|
||||
pub fn into_stream_chunks(self, cx: Scope) -> Vec<StreamChunk> {
|
||||
let mut chunks = Vec::new();
|
||||
self.into_stream_chunks_helper(cx, &mut chunks);
|
||||
chunks
|
||||
}
|
||||
|
||||
fn into_stream_chunks_helper(
|
||||
self,
|
||||
cx: Scope,
|
||||
chunks: &mut Vec<StreamChunk>,
|
||||
) {
|
||||
match self {
|
||||
View::Suspense(id, _) => {
|
||||
let id = id.to_string();
|
||||
if let Some((_, fragment)) = cx.take_pending_fragment(&id) {
|
||||
chunks.push(StreamChunk::Async(fragment));
|
||||
}
|
||||
}
|
||||
View::Text(node) => chunks.push(StreamChunk::Sync(node.content)),
|
||||
View::Component(node) => {
|
||||
cfg_if! {
|
||||
if #[cfg(debug_assertions)] {
|
||||
let name = crate::ssr::to_kebab_case(&node.name);
|
||||
chunks.push(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-start-->"#, HydrationCtx::to_string(&node.id, false)).into()));
|
||||
for child in node.children {
|
||||
child.into_stream_chunks_helper(cx, chunks);
|
||||
}
|
||||
chunks.push(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-end-->"#, HydrationCtx::to_string(&node.id, true)).into()));
|
||||
} else {
|
||||
for child in node.children {
|
||||
child.into_stream_chunks_helper(cx, chunks);
|
||||
}
|
||||
chunks.push(StreamChunk::Sync(format!(r#"<!--hk={}-->"#, HydrationCtx::to_string(&node.id, true))))
|
||||
}
|
||||
}
|
||||
}
|
||||
View::Element(el) => {
|
||||
if let Some(prerendered) = el.prerendered {
|
||||
chunks.push(StreamChunk::Sync(prerendered))
|
||||
} else {
|
||||
let tag_name = el.name;
|
||||
|
||||
let mut inner_html = None;
|
||||
|
||||
let attrs = el
|
||||
.attrs
|
||||
.into_iter()
|
||||
.filter_map(
|
||||
|(name, value)| -> Option<Cow<'static, str>> {
|
||||
if value.is_empty() {
|
||||
Some(format!(" {name}").into())
|
||||
} else if name == "inner_html" {
|
||||
inner_html = Some(value);
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
format!(
|
||||
" {name}=\"{}\"",
|
||||
html_escape::encode_double_quoted_attribute(&value)
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.join("");
|
||||
|
||||
if el.is_void {
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!("<{tag_name}{attrs}/>").into(),
|
||||
));
|
||||
} else if let Some(inner_html) = inner_html {
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!(
|
||||
"<{tag_name}{attrs}>{inner_html}</{tag_name}>"
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
} else {
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!("<{tag_name}{attrs}>").into(),
|
||||
));
|
||||
for child in el.children {
|
||||
child.into_stream_chunks_helper(cx, chunks);
|
||||
}
|
||||
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!("</{tag_name}>").into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
View::Transparent(_) => {}
|
||||
View::CoreComponent(node) => {
|
||||
let (id, name, wrap, content) = match node {
|
||||
CoreComponent::Unit(u) => (
|
||||
u.id.clone(),
|
||||
"",
|
||||
false,
|
||||
Box::new(move |chunks: &mut Vec<StreamChunk>| {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!(
|
||||
"<!--hk={}|leptos-unit-->",
|
||||
HydrationCtx::to_string(&u.id, true)
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!(
|
||||
"<!--hk={}-->",
|
||||
HydrationCtx::to_string(&u.id, true)
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
})
|
||||
as Box<dyn FnOnce(&mut Vec<StreamChunk>)>,
|
||||
),
|
||||
CoreComponent::DynChild(node) => {
|
||||
let child = node.child.take();
|
||||
(
|
||||
node.id,
|
||||
"dyn-child",
|
||||
true,
|
||||
Box::new(move |chunks: &mut Vec<StreamChunk>| {
|
||||
if let Some(child) = *child {
|
||||
// On debug builds, `DynChild` has two marker nodes,
|
||||
// so there is no way for the text to be merged with
|
||||
// surrounding text when the browser parses the HTML,
|
||||
// but in release, `DynChild` only has a trailing marker,
|
||||
// and the browser automatically merges the dynamic text
|
||||
// into one single node, so we need to artificially make the
|
||||
// browser create the dynamic text as it's own text node
|
||||
if let View::Text(t) = child {
|
||||
chunks.push(
|
||||
if !cfg!(debug_assertions) {
|
||||
StreamChunk::Sync(
|
||||
format!("<!>{}", t.content)
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
StreamChunk::Sync(t.content)
|
||||
},
|
||||
);
|
||||
} else {
|
||||
child.into_stream_chunks_helper(
|
||||
cx, chunks,
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
as Box<dyn FnOnce(&mut Vec<StreamChunk>)>,
|
||||
)
|
||||
}
|
||||
CoreComponent::Each(node) => {
|
||||
let children = node.children.take();
|
||||
(
|
||||
node.id,
|
||||
"each",
|
||||
true,
|
||||
Box::new(move |chunks: &mut Vec<StreamChunk>| {
|
||||
for node in children.into_iter().flatten() {
|
||||
let id = node.id;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!(
|
||||
"<!--hk={}|leptos-each-item-start-->",
|
||||
HydrationCtx::to_string(&id, false)
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
node.child.into_stream_chunks_helper(
|
||||
cx, chunks,
|
||||
);
|
||||
chunks.push(StreamChunk::Sync(
|
||||
format!(
|
||||
"<!--hk={}|leptos-each-item-end-->",
|
||||
HydrationCtx::to_string(&id, true)
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
})
|
||||
as Box<dyn FnOnce(&mut Vec<StreamChunk>)>,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if wrap {
|
||||
cfg_if! {
|
||||
if #[cfg(debug_assertions)] {
|
||||
chunks.push(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-start-->", HydrationCtx::to_string(&id, false)).into()));
|
||||
content(chunks);
|
||||
chunks.push(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-end-->", HydrationCtx::to_string(&id, true)).into()));
|
||||
} else {
|
||||
let _ = name;
|
||||
content(chunks);
|
||||
chunks.push(StreamChunk::Sync(format!("<!--hk={}-->", HydrationCtx::to_string(&id, true)).into()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content(chunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ pub fn view(tokens: TokenStream) -> TokenStream {
|
||||
Ok(nodes) => render_view(
|
||||
&proc_macro2::Ident::new(&cx.to_string(), cx.span()),
|
||||
&nodes,
|
||||
Mode::default(),
|
||||
Mode::Client, //Mode::default(),
|
||||
global_class.as_ref(),
|
||||
),
|
||||
Err(error) => error.to_compile_error(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#![forbid(unsafe_code)]
|
||||
use crate::{runtime::PinnedFuture, ResourceId};
|
||||
use crate::{runtime::PinnedFuture, suspense::StreamChunk, ResourceId};
|
||||
use cfg_if::cfg_if;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -8,8 +8,13 @@ pub struct SharedContext {
|
||||
pub pending_resources: HashSet<ResourceId>,
|
||||
pub resolved_resources: HashMap<ResourceId, String>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
// index String is the fragment ID: tuple is (ID of previous component, Future of <Suspense/> HTML when resolved)
|
||||
pub pending_fragments: HashMap<String, (String, PinnedFuture<String>)>,
|
||||
// index String is the fragment ID: tuple is
|
||||
// `(
|
||||
// Future of <Suspense/> HTML when resolved (out-of-order)
|
||||
// Future of additional stream chunks when resolved (in-order)
|
||||
// )`
|
||||
pub pending_fragments:
|
||||
HashMap<String, (PinnedFuture<String>, PinnedFuture<Vec<StreamChunk>>)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SharedContext {
|
||||
|
||||
@@ -85,7 +85,7 @@ mod slice;
|
||||
mod spawn;
|
||||
mod spawn_microtask;
|
||||
mod stored_value;
|
||||
mod suspense;
|
||||
pub mod suspense;
|
||||
|
||||
pub use context::*;
|
||||
pub use effect::*;
|
||||
@@ -103,7 +103,7 @@ pub use slice::*;
|
||||
pub use spawn::*;
|
||||
pub use spawn_microtask::*;
|
||||
pub use stored_value::*;
|
||||
pub use suspense::*;
|
||||
pub use suspense::SuspenseContext;
|
||||
|
||||
/// Trait implemented for all signal types which you can `get` a value
|
||||
/// from, such as [`ReadSignal`],
|
||||
|
||||
@@ -167,6 +167,57 @@ impl RuntimeId {
|
||||
)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub(crate) fn create_many_signals_with_map<T, U>(
|
||||
self,
|
||||
cx: Scope,
|
||||
values: impl IntoIterator<Item = T>,
|
||||
map_fn: impl Fn((ReadSignal<T>, WriteSignal<T>)) -> U,
|
||||
) -> Vec<U>
|
||||
where
|
||||
T: Any + 'static,
|
||||
{
|
||||
with_runtime(self, move |runtime| {
|
||||
let mut signals = runtime.signals.borrow_mut();
|
||||
let properties = runtime.scopes.borrow();
|
||||
let mut properties = properties
|
||||
.get(cx.id)
|
||||
.expect(
|
||||
"tried to add signals to a scope that has been disposed",
|
||||
)
|
||||
.borrow_mut();
|
||||
let values = values.into_iter();
|
||||
let size = values.size_hint().0;
|
||||
signals.reserve(size);
|
||||
properties.reserve(size);
|
||||
values
|
||||
.map(|value| signals.insert(Rc::new(RefCell::new(value))))
|
||||
.map(|id| {
|
||||
properties.push(ScopeProperty::Signal(id));
|
||||
(
|
||||
ReadSignal {
|
||||
runtime: self,
|
||||
id,
|
||||
ty: PhantomData,
|
||||
#[cfg(debug_assertions)]
|
||||
defined_at: std::panic::Location::caller(),
|
||||
},
|
||||
WriteSignal {
|
||||
runtime: self,
|
||||
id,
|
||||
ty: PhantomData,
|
||||
#[cfg(debug_assertions)]
|
||||
defined_at: std::panic::Location::caller(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.map(map_fn)
|
||||
.collect()
|
||||
})
|
||||
.expect("tried to create a signal in a runtime that has been disposed")
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub(crate) fn create_rw_signal<T>(self, value: T) -> RwSignal<T>
|
||||
where
|
||||
T: Any + 'static,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
use crate::{
|
||||
runtime::{with_runtime, RuntimeId},
|
||||
suspense::StreamChunk,
|
||||
EffectId, PinnedFuture, ResourceId, SignalId, SuspenseContext,
|
||||
};
|
||||
use futures::stream::FuturesUnordered;
|
||||
@@ -361,32 +362,37 @@ impl Scope {
|
||||
pub fn register_suspense(
|
||||
&self,
|
||||
context: SuspenseContext,
|
||||
key_before_suspense: &str,
|
||||
key: &str,
|
||||
resolver: impl FnOnce() -> String + 'static,
|
||||
out_of_order_resolver: impl FnOnce() -> String + 'static,
|
||||
in_order_resolver: impl FnOnce() -> Vec<StreamChunk> + 'static,
|
||||
) {
|
||||
use crate::create_isomorphic_effect;
|
||||
use futures::StreamExt;
|
||||
|
||||
_ = with_runtime(self.runtime, |runtime| {
|
||||
let mut shared_context = runtime.shared_context.borrow_mut();
|
||||
let (tx, mut rx) = futures::channel::mpsc::unbounded();
|
||||
let (tx1, mut rx1) = futures::channel::mpsc::unbounded();
|
||||
let (tx2, mut rx2) = futures::channel::mpsc::unbounded();
|
||||
|
||||
create_isomorphic_effect(*self, move |_| {
|
||||
let pending =
|
||||
context.pending_resources.try_with(|n| *n).unwrap_or(0);
|
||||
if pending == 0 {
|
||||
_ = tx.unbounded_send(());
|
||||
_ = tx1.unbounded_send(());
|
||||
_ = tx2.unbounded_send(());
|
||||
}
|
||||
});
|
||||
|
||||
shared_context.pending_fragments.insert(
|
||||
key.to_string(),
|
||||
(
|
||||
key_before_suspense.to_string(),
|
||||
Box::pin(async move {
|
||||
rx.next().await;
|
||||
resolver()
|
||||
rx1.next().await;
|
||||
out_of_order_resolver()
|
||||
}),
|
||||
Box::pin(async move {
|
||||
rx2.next().await;
|
||||
in_order_resolver()
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -394,17 +400,35 @@ impl Scope {
|
||||
}
|
||||
|
||||
/// The set of all HTML fragments currently pending.
|
||||
/// Returns a tuple of the hydration ID of the previous element, and a pinned `Future` that will yield the
|
||||
/// `<Suspense/>` HTML when all resources are resolved.
|
||||
///
|
||||
/// The keys are hydration IDs. Valeus are tuples of two pinned
|
||||
/// `Future`s that return content for out-of-order and in-order streaming, respectively.
|
||||
pub fn pending_fragments(
|
||||
&self,
|
||||
) -> HashMap<String, (String, PinnedFuture<String>)> {
|
||||
) -> HashMap<String, (PinnedFuture<String>, PinnedFuture<Vec<StreamChunk>>)>
|
||||
{
|
||||
with_runtime(self.runtime, |runtime| {
|
||||
let mut shared_context = runtime.shared_context.borrow_mut();
|
||||
std::mem::take(&mut shared_context.pending_fragments)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Takes the pending HTML for a single `<Suspense/>` node.
|
||||
///
|
||||
/// Returns a tuple of two pinned `Future`s that return content for out-of-order
|
||||
/// and in-order streaming, respectively.
|
||||
pub fn take_pending_fragment(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Option<(PinnedFuture<String>, PinnedFuture<Vec<StreamChunk>>)> {
|
||||
with_runtime(self.runtime, |runtime| {
|
||||
let mut shared_context = runtime.shared_context.borrow_mut();
|
||||
shared_context.pending_fragments.remove(id)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ScopeDisposer {
|
||||
|
||||
@@ -69,6 +69,50 @@ pub fn create_signal<T>(
|
||||
s
|
||||
}
|
||||
|
||||
/// Works exactly as [create_signal], but creates multiple signals at once.
|
||||
#[cfg_attr(
|
||||
debug_assertions,
|
||||
instrument(
|
||||
level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
scope = ?cx.id,
|
||||
ty = %std::any::type_name::<T>()
|
||||
)
|
||||
)
|
||||
)]
|
||||
#[track_caller]
|
||||
pub fn create_many_signals<T>(
|
||||
cx: Scope,
|
||||
values: impl IntoIterator<Item = T>,
|
||||
) -> Vec<(ReadSignal<T>, WriteSignal<T>)> {
|
||||
cx.runtime.create_many_signals_with_map(cx, values, |x| x)
|
||||
}
|
||||
|
||||
/// Works exactly as [create_many_signals], but applies the map function to each signal pair.
|
||||
#[cfg_attr(
|
||||
debug_assertions,
|
||||
instrument(
|
||||
level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
scope = ?cx.id,
|
||||
ty = %std::any::type_name::<T>()
|
||||
)
|
||||
)
|
||||
)]
|
||||
#[track_caller]
|
||||
pub fn create_many_signals_mapped<T, U>(
|
||||
cx: Scope,
|
||||
values: impl IntoIterator<Item = T>,
|
||||
map_fn: impl Fn((ReadSignal<T>, WriteSignal<T>)) -> U + 'static,
|
||||
) -> Vec<U>
|
||||
where
|
||||
T: 'static,
|
||||
{
|
||||
cx.runtime.create_many_signals_with_map(cx, values, map_fn)
|
||||
}
|
||||
|
||||
/// Creates a signal that always contains the most recent value emitted by a
|
||||
/// [Stream](futures::stream::Stream).
|
||||
/// If the stream has not yet emitted a value since the signal was created, the signal's
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Types that handle asynchronous data loading via `<Suspense/>`.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
use crate::{create_signal, queue_microtask, ReadSignal, Scope, WriteSignal};
|
||||
use futures::Future;
|
||||
use std::{borrow::Cow, pin::Pin};
|
||||
|
||||
/// Tracks [Resource](crate::Resource)s that are read under a suspense context,
|
||||
/// i.e., within a [`Suspense`](https://docs.rs/leptos_core/latest/leptos_core/fn.Suspense.html) component.
|
||||
@@ -61,3 +65,20 @@ impl SuspenseContext {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a chunk in a stream of HTML.
|
||||
pub enum StreamChunk {
|
||||
/// A chunk of synchronous HTML.
|
||||
Sync(Cow<'static, str>),
|
||||
/// A future that resolves to be a list of additional chunks.
|
||||
Async(Pin<Box<dyn Future<Output = Vec<StreamChunk>>>>),
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for StreamChunk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StreamChunk::Sync(data) => write!(f, "StreamChunk::Sync({data:?})"),
|
||||
StreamChunk::Async(_) => write!(f, "StreamChunk::Async(_)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,23 @@ impl MetaContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(cx: Scope) -> String {
|
||||
let meta = use_context::<MetaContext>(cx);
|
||||
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();
|
||||
format!("{head}</head><body{body_meta}>")
|
||||
}
|
||||
|
||||
/// Describes a value that is either a static or a reactive string, i.e.,
|
||||
/// a [String], a [&str], or a reactive `Fn() -> String`.
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch},
|
||||
ParamsMap, RouterContext,
|
||||
ParamsMap, RouterContext, SsrMode,
|
||||
};
|
||||
use leptos::{leptos_dom::Transparent, *};
|
||||
use std::{
|
||||
@@ -25,6 +25,9 @@ pub fn Route<E, F, P>(
|
||||
/// that takes a [Scope] and returns an [Element] (like `|cx| view! { cx, <p>"Show this"</p> })`
|
||||
/// or `|cx| view! { cx, <MyComponent/>` } or even, for a component with no props, `MyComponent`).
|
||||
view: F,
|
||||
/// The mode that this route prefers during server-side rendering. Defaults to out-of-order streaming.
|
||||
#[prop(optional)]
|
||||
ssr: SsrMode,
|
||||
/// `children` may be empty or include nested routes.
|
||||
#[prop(optional)]
|
||||
children: Option<Children>,
|
||||
@@ -39,6 +42,7 @@ where
|
||||
children: Option<Children>,
|
||||
path: String,
|
||||
view: Rc<dyn Fn(Scope) -> View>,
|
||||
ssr_mode: SsrMode,
|
||||
) -> RouteDefinition {
|
||||
let children = children
|
||||
.map(|children| {
|
||||
@@ -66,6 +70,7 @@ where
|
||||
path,
|
||||
children,
|
||||
view,
|
||||
ssr_mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +79,7 @@ where
|
||||
children,
|
||||
path.to_string(),
|
||||
Rc::new(move |cx| view(cx).into_view(cx)),
|
||||
ssr,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{Branch, RouterIntegrationContext, ServerIntegration};
|
||||
use crate::{Branch, RouterIntegrationContext, ServerIntegration, SsrMode};
|
||||
use leptos::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct PossibleBranchContext(pub(crate) Rc<RefCell<Vec<Branch>>>);
|
||||
/// to work with their router
|
||||
pub fn generate_route_list_inner<IV>(
|
||||
app_fn: impl FnOnce(Scope) -> IV + 'static,
|
||||
) -> Vec<String>
|
||||
) -> Vec<(String, SsrMode)>
|
||||
where
|
||||
IV: IntoView + 'static,
|
||||
{
|
||||
@@ -31,7 +31,15 @@ where
|
||||
branches
|
||||
.iter()
|
||||
.flat_map(|branch| {
|
||||
branch.routes.last().map(|route| route.pattern.clone())
|
||||
let mode = branch
|
||||
.routes
|
||||
.iter()
|
||||
.map(|route| route.key.ssr_mode)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
let pattern =
|
||||
branch.routes.last().map(|route| route.pattern.clone());
|
||||
pattern.map(|pattern| (pattern, mode))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
|
||||
@@ -194,9 +194,11 @@ mod history;
|
||||
mod hooks;
|
||||
#[doc(hidden)]
|
||||
pub mod matching;
|
||||
mod render_mode;
|
||||
pub use components::*;
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
pub use extract_routes::*;
|
||||
pub use history::*;
|
||||
pub use hooks::*;
|
||||
pub use matching::{RouteDefinition, *};
|
||||
pub use render_mode::*;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::SsrMode;
|
||||
use leptos::{leptos_dom::View, *};
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -14,6 +15,8 @@ pub struct RouteDefinition {
|
||||
pub children: Vec<RouteDefinition>,
|
||||
/// The view that should be displayed when this route is matched.
|
||||
pub view: Rc<dyn Fn(Scope) -> View>,
|
||||
/// The mode this route prefers during server-side rendering.
|
||||
pub ssr_mode: SsrMode,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RouteDefinition {
|
||||
@@ -21,6 +24,7 @@ impl std::fmt::Debug for RouteDefinition {
|
||||
f.debug_struct("RouteDefinition")
|
||||
.field("path", &self.path)
|
||||
.field("children", &self.children)
|
||||
.field("ssr_mode", &self.ssr_mode)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
33
router/src/render_mode.rs
Normal file
33
router/src/render_mode.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
/// Indicates which rendering mode should be used for this route during server-side rendering.
|
||||
///
|
||||
/// Leptos supports four different ways to render HTML that contains `async` data loaded
|
||||
/// under `<Suspense/>`.
|
||||
/// 1. **Synchronous**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the client, replacing `fallback` once they're loaded.
|
||||
/// - *Pros*: App shell appears very quickly: great TTFB (time to first byte).
|
||||
/// - *Cons*: Resources load relatively slowly; you need to wait for JS + Wasm to load before even making a request.
|
||||
/// 2. **Out-of-order streaming**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the **server**, streaming it down to the client as it resolves, and streaming down HTML for `Suspense` nodes.
|
||||
/// - *Pros*: Combines the best of **synchronous** and **`async`**, with a very fast shell and resources that begin loading on the server.
|
||||
/// - *Cons*: Requires JS for suspended fragments to appear in correct order. Weaker meta tag support when it depends on data that's under suspense (has already streamed down `<head>`)
|
||||
/// 3. **In-order streaming**: Walk through the tree, returning HTML synchronously as in synchronous rendering and out-of-order streaming until you hit a `Suspense`. At that point, wait for all its data to load, then render it, then the rest of the tree.
|
||||
/// - *Pros*: Does not require JS for HTML to appear in correct order.
|
||||
/// - *Cons*: Loads the shell more slowly than out-of-order streaming or synchronous rendering because it needs to pause at every `Suspense`. Cannot begin hydration until the entire page has loaded, so earlier pieces
|
||||
/// of the page will not be interactive until the suspended chunks have loaded.
|
||||
/// 4. **`async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
|
||||
/// - *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server.
|
||||
/// - *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client.
|
||||
///
|
||||
/// The mode defaults to out-of-order streaming. For a path that includes multiple nested routes, the most
|
||||
/// restrictive mode will be used: i.e., if even a single nested route asks for `async` rendering, the whole initial
|
||||
/// request will be rendered `async`. (`async` is the most restricted requirement, followed by in-order, out-of-order, and synchronous.)
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum SsrMode {
|
||||
OutOfOrder,
|
||||
InOrder,
|
||||
Async,
|
||||
}
|
||||
|
||||
impl Default for SsrMode {
|
||||
fn default() -> Self {
|
||||
Self::OutOfOrder
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user