diff --git a/Cargo.lock b/Cargo.lock
index 477d5cb32..70063efc9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -308,6 +308,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "async-once-cell"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
+
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -465,6 +471,12 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "base16"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -1813,6 +1825,8 @@ dependencies = [
"typed-builder",
"typed-builder-macro",
"wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm_split_helpers",
"web-sys",
]
@@ -3360,6 +3374,17 @@ dependencies = [
"digest",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -4311,6 +4336,26 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasm_split_helpers"
+version = "0.1.0"
+dependencies = [
+ "async-once-cell",
+ "wasm_split_macros",
+]
+
+[[package]]
+name = "wasm_split_macros"
+version = "0.1.0"
+dependencies = [
+ "base16",
+ "digest",
+ "quote",
+ "sha2",
+ "syn 2.0.104",
+ "wasm-bindgen",
+]
+
[[package]]
name = "web-sys"
version = "0.3.77"
diff --git a/Cargo.toml b/Cargo.toml
index 041855297..9f9b2129b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -71,8 +71,11 @@ server_fn = { path = "./server_fn", version = "0.8.4" }
server_fn_macro = { path = "./server_fn_macro", version = "0.8.4" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.8.4" }
tachys = { path = "./tachys", version = "0.2.5" }
+wasm_split_helpers = { path = "./wasm_split", version = "0.1.0" }
+wasm_split_macros = { path = "./wasm_split_macros", version = "0.1.0" }
# members deps
+async-once-cell = { default-features = false, version = "0.5.3" }
itertools = { default-features = false, version = "0.14.0" }
convert_case = { default-features = false, version = "0.8.0" }
serde_json = { default-features = false, version = "1.0.140" }
@@ -165,6 +168,9 @@ rustversion = { default-features = false, version = "1.0.21" }
getrandom = { default-features = false, version = "0.3.3" }
actix-files = { default-features = false, version = "0.6.6" }
async-lock = { default-features = false, version = "3.4.0" }
+base16 = { default-features = false, version = "0.2.1" }
+digest = { default-features = false, version = "0.10.7" }
+sha2 = { default-features = false, version = "0.10.8" }
[profile.release]
codegen-units = 1
diff --git a/examples/cargo-make/cargo-leptos-split-webdriver-test.toml b/examples/cargo-make/cargo-leptos-split-webdriver-test.toml
new file mode 100644
index 000000000..14b330e64
--- /dev/null
+++ b/examples/cargo-make/cargo-leptos-split-webdriver-test.toml
@@ -0,0 +1,11 @@
+extend = [
+ { path = "./cargo-leptos.toml" },
+ { path = "../cargo-make/webdriver.toml" },
+]
+
+[tasks.integration-test]
+dependencies = [
+ "install-cargo-leptos",
+ "start-webdriver",
+ "cargo-leptos-e2e-split",
+]
diff --git a/examples/cargo-make/cargo-leptos.toml b/examples/cargo-make/cargo-leptos.toml
index 9a44b0b57..a4d44f68d 100644
--- a/examples/cargo-make/cargo-leptos.toml
+++ b/examples/cargo-make/cargo-leptos.toml
@@ -11,6 +11,10 @@ args = ["--locked"]
command = "cargo"
args = ["leptos", "end-to-end"]
+[tasks.cargo-leptos-e2e-split]
+command = "cargo"
+args = ["leptos", "end-to-end", "--split"]
+
[tasks.build]
clear = true
command = "cargo"
diff --git a/examples/hackernews_axum/src/lib.rs b/examples/hackernews_axum/src/lib.rs
index 04c2efbb8..f8d96d147 100644
--- a/examples/hackernews_axum/src/lib.rs
+++ b/examples/hackernews_axum/src/lib.rs
@@ -4,7 +4,7 @@ mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router, RoutingProgress},
- OptionalParamSegment, ParamSegment, StaticSegment,
+ Lazy, OptionalParamSegment, ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -44,8 +44,8 @@ pub fn App() -> impl IntoView {
-
-
+ ::new()}/>
+ ::new()}/>
diff --git a/examples/hackernews_axum/src/routes/story.rs b/examples/hackernews_axum/src/routes/story.rs
index 324a79676..98544f665 100644
--- a/examples/hackernews_axum/src/routes/story.rs
+++ b/examples/hackernews_axum/src/routes/story.rs
@@ -1,24 +1,38 @@
-use crate::api;
+use crate::api::{self, Story};
use leptos::{either::Either, prelude::*};
use leptos_meta::Meta;
-use leptos_router::{components::A, hooks::use_params_map};
+use leptos_router::{
+ components::A, hooks::use_params_map, lazy_route, LazyRoute,
+};
-#[component]
-pub fn Story() -> impl IntoView {
- let params = use_params_map();
- let story = Resource::new_blocking(
- move || params.read().get("id").unwrap_or_default(),
- move |id| async move {
- if id.is_empty() {
- None
- } else {
- api::fetch_api::(&api::story(&format!("item/{id}")))
+#[derive(Debug)]
+pub struct StoryRoute {
+ story: Resource>,
+}
+
+#[lazy_route]
+impl LazyRoute for StoryRoute {
+ fn data() -> Self {
+ let params = use_params_map();
+ let story = Resource::new_blocking(
+ move || params.read().get("id").unwrap_or_default(),
+ move |id| async move {
+ if id.is_empty() {
+ None
+ } else {
+ api::fetch_api::(&api::story(&format!(
+ "item/{id}"
+ )))
.await
- }
- },
- );
+ }
+ },
+ );
+ Self { story }
+ }
- Suspense(SuspenseProps::builder().fallback(|| "Loading...").children(ToChildren::to_children(move || Suspend::new(async move {
+ fn view(this: Self) -> AnyView {
+ let StoryRoute { story } = this;
+ Suspense(SuspenseProps::builder().fallback(|| "Loading...").children(ToChildren::to_children(move || Suspend::new(async move {
match story.await.clone() {
None => Either::Left("Story not found."),
Some(story) => {
@@ -61,7 +75,8 @@ pub fn Story() -> impl IntoView {
})
}
}
- }))).build())
+ }))).build()).into_any()
+ }
}
#[component]
diff --git a/examples/hackernews_axum/src/routes/users.rs b/examples/hackernews_axum/src/routes/users.rs
index 112120a32..656cd6eb6 100644
--- a/examples/hackernews_axum/src/routes/users.rs
+++ b/examples/hackernews_axum/src/routes/users.rs
@@ -1,46 +1,58 @@
use crate::api::{self, User};
use leptos::{either::Either, prelude::*, server::Resource};
-use leptos_router::hooks::use_params_map;
+use leptos_router::{hooks::use_params_map, lazy_route, LazyRoute};
-#[component]
-pub fn User() -> impl IntoView {
- let params = use_params_map();
- let user = Resource::new(
- move || params.read().get("id").unwrap_or_default(),
- move |id| async move {
- if id.is_empty() {
- None
- } else {
- api::fetch_api::(&api::user(&id)).await
- }
- },
- );
- view! {
-
-
- {move || Suspend::new(async move { match user.await.clone() {
- None => Either::Left(view! { "User not found." }),
- Some(user) => Either::Right(view! {
-
- })
- }})}
-
-
+#[derive(Debug)]
+pub struct UserRoute {
+ user: Resource>,
+}
+
+#[lazy_route]
+impl LazyRoute for UserRoute {
+ fn data() -> Self {
+ let params = use_params_map();
+ let user = Resource::new(
+ move || params.read().get("id").unwrap_or_default(),
+ move |id| async move {
+ if id.is_empty() {
+ None
+ } else {
+ api::fetch_api::(&api::user(&id)).await
+ }
+ },
+ );
+ UserRoute { user }
+ }
+
+ fn view(this: Self) -> AnyView {
+ let UserRoute { user } = this;
+ view! {
+
+
+ {move || Suspend::new(async move { match user.await.clone() {
+ None => Either::Left(view! { "User not found." }),
+ Some(user) => Either::Right(view! {
+
+ })
+ }})}
+
+
+ }.into_any()
}
}
diff --git a/examples/lazy_routes/Cargo.toml b/examples/lazy_routes/Cargo.toml
new file mode 100644
index 000000000..c42445681
--- /dev/null
+++ b/examples/lazy_routes/Cargo.toml
@@ -0,0 +1,95 @@
+[package]
+name = "lazy_routes"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+axum = { version = "0.8.1", optional = true }
+console_error_panic_hook = "0.1.7"
+console_log = "1.0"
+leptos = { path = "../../leptos", features = ["tracing"] }
+leptos_meta = { path = "../../meta" }
+leptos_axum = { path = "../../integrations/axum", optional = true }
+leptos_router = { path = "../../router" }
+serde = { version = "1.0", features = ["derive"] }
+thiserror = "1.0"
+tokio = { version = "1.39", features = [
+ "rt-multi-thread",
+ "macros",
+ "time",
+], optional = true }
+wasm-bindgen = "0.2.92"
+futures = "0.3.31"
+serde_json = "1.0.140"
+gloo-timers = { version = "0.3", features = ["futures"] }
+
+[features]
+hydrate = ["leptos/hydrate"]
+ssr = [
+ "dep:axum",
+ "dep:tokio",
+ "leptos/ssr",
+ "leptos_meta/ssr",
+ "dep:leptos_axum",
+ "leptos_router/ssr",
+]
+
+[profile.release]
+panic = "abort"
+
+[profile.wasm-release]
+inherits = "release"
+opt-level = 'z'
+lto = true
+codegen-units = 1
+panic = "abort"
+
+[package.metadata.cargo-all-features]
+denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
+skip_feature_sets = [["ssr", "hydrate"]]
+
+[package.metadata.leptos]
+# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
+output-name = "regression"
+# 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"
+# 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 = "cargo make test-ui"
+end2end-dir = "e2e"
+# 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
diff --git a/examples/lazy_routes/LICENSE b/examples/lazy_routes/LICENSE
new file mode 100644
index 000000000..c97ff293b
--- /dev/null
+++ b/examples/lazy_routes/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Leptos
+
+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.
diff --git a/examples/lazy_routes/Makefile.toml b/examples/lazy_routes/Makefile.toml
new file mode 100644
index 000000000..dc29ed4e4
--- /dev/null
+++ b/examples/lazy_routes/Makefile.toml
@@ -0,0 +1,8 @@
+extend = [
+ { path = "../cargo-make/main.toml" },
+ { path = "../cargo-make/cargo-leptos-split-webdriver-test.toml" },
+]
+
+[env]
+
+CLIENT_PROCESS_NAME = "regression"
diff --git a/examples/lazy_routes/README.md b/examples/lazy_routes/README.md
new file mode 100644
index 000000000..834497bdc
--- /dev/null
+++ b/examples/lazy_routes/README.md
@@ -0,0 +1,8 @@
+# Regression Tests
+
+This example functions as a catch-all for all current and future regression
+test cases that typically happens at integration.
+
+## Quick Start
+
+Run `cargo leptos watch` to run this example.
diff --git a/examples/lazy_routes/assets/favicon.ico b/examples/lazy_routes/assets/favicon.ico
new file mode 100644
index 000000000..2ba8527cb
Binary files /dev/null and b/examples/lazy_routes/assets/favicon.ico differ
diff --git a/examples/lazy_routes/e2e/Cargo.toml b/examples/lazy_routes/e2e/Cargo.toml
new file mode 100644
index 000000000..f96b232ad
--- /dev/null
+++ b/examples/lazy_routes/e2e/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "lazy_routes_e2e"
+version = "0.1.0"
+edition = "2021"
+
+[dev-dependencies]
+anyhow = "1.0"
+async-trait = "0.1.81"
+cucumber = "0.21.1"
+fantoccini = "0.21.1"
+pretty_assertions = "1.4"
+serde_json = "1.0"
+tokio = { version = "1.39", features = ["macros", "rt-multi-thread", "time"] }
+url = "2.5"
+
+[[test]]
+name = "app_suite"
+harness = false # Allow Cucumber to print output instead of libtest
diff --git a/examples/lazy_routes/e2e/Makefile.toml b/examples/lazy_routes/e2e/Makefile.toml
new file mode 100644
index 000000000..172931e96
--- /dev/null
+++ b/examples/lazy_routes/e2e/Makefile.toml
@@ -0,0 +1,20 @@
+extend = { path = "../../cargo-make/main.toml" }
+
+[tasks.test]
+env = { RUN_AUTOMATICALLY = false }
+condition = { env_true = ["RUN_AUTOMATICALLY"] }
+
+[tasks.ci]
+
+[tasks.test-ui]
+command = "cargo"
+args = [
+ "test",
+ "--test",
+ "app_suite",
+ "--",
+ "--retry",
+ "2",
+ "--fail-fast",
+ "${@}",
+]
diff --git a/examples/lazy_routes/e2e/README.md b/examples/lazy_routes/e2e/README.md
new file mode 100644
index 000000000..c3e96b3b1
--- /dev/null
+++ b/examples/lazy_routes/e2e/README.md
@@ -0,0 +1,30 @@
+# Lazy Routes
+
+This example demonstrates how to split the WASM bundle that is sent to the client into multiple binaries, which can be lazy-loaded, either independently or in a way that's integrated into the router.
+
+Without code splitting, the entire application is compiled to a monolithic WASM binary, the size of which grows in proportion to the complexity of the application. This means that the time to interactive (TTI) for any page is proportional to the size of the entire application, not only that page.
+
+Code splitting allows you to lazy-load some functions, by splitting off the WASM binary code for certain functions into separate files, which can be downloaded as needed. This minimizes initial TTI for any page, and then amortizes the cost of loading the binary over the lifetime of the application session.
+
+In many cases, this can be done with minimal or no cost.
+
+Lazy loading can be used in two ways, each of which is shown in the example.
+
+## `#[lazy]` macro
+
+`#[lazy]` is an attribute macro that can be used to annotate an `async fn` in order to split its code out into a separate file that will be loaded on demand, when compiled with `cargo leptos --split`.
+
+This has some limitations (for example, it must return concrete types) but can be used for most functions.
+
+## `LazyRoute`
+
+`LazyRoute` is a specialized application of `#[lazy]` that allows you to define an entire route/page of your application as being lazy-loaded.
+
+Creating a lazy route requires you to split the route into two parts:
+
+1. `data()`: A synchronous method that should be used to start loading any async data used by the page, for example by creating a `Resource`
+2. `view()`: An async (because lazy-loaded) method that renders the view.
+
+The purpose of splitting these into two parts is to avoid a “waterfall,” in which the browser first waits for a lazy-loaded WASM chunk that defines the page, _then_ makes a second request to the server to load the relevant data. Instead, a `LazyRoute` will begin loading resources created in the `data` method while lazy-loading the component body in the `view`, then render the route.
+
+This means that in many cases, the data loading “hides” the cost of the lazy-loading; i.e., the page needs to wait for the data to load, so the fact that it is waiting concurrently for the lazy-loaded view means that the lazy loading does not cost anything additional in terms of page load time.
diff --git a/examples/lazy_routes/e2e/features/basic.feature b/examples/lazy_routes/e2e/features/basic.feature
new file mode 100644
index 000000000..7ae37ec3f
--- /dev/null
+++ b/examples/lazy_routes/e2e/features/basic.feature
@@ -0,0 +1,33 @@
+@basic
+Feature: Check that each page hydrates correctly
+
+ Scenario: Page A is rendered correctly.
+ Given I see the app
+ Then I see the page is View A
+
+ Scenario: Page A hydrates and allows navigating to page B.
+ Given I see the app
+ When I select the link B
+ Then I see the navigating indicator
+ When I wait for a second
+ Then I see the page is View B
+
+ Scenario: Page B is rendered correctly.
+ When I open the app at /b
+ Then I see the page is View B
+
+ Scenario: Page B hydrates and allows navigating to page C.
+ When I open the app at /b
+ When I select the link C
+ Then I see the navigating indicator
+ When I wait for a second
+ Then I see the page is View C
+
+ Scenario: Page C is rendered correctly.
+ When I open the app at /c
+ Then I see the page is View C
+
+ Scenario: Page C hydrates and allows navigating to page A.
+ When I open the app at /c
+ When I select the link A
+ Then I see the page is View A
\ No newline at end of file
diff --git a/examples/lazy_routes/e2e/features/duplicate_name.feature b/examples/lazy_routes/e2e/features/duplicate_name.feature
new file mode 100644
index 000000000..5c5dd6b29
--- /dev/null
+++ b/examples/lazy_routes/e2e/features/duplicate_name.feature
@@ -0,0 +1,15 @@
+@duplicate_names
+Feature: Lazy functions can share the same name
+
+ Scenario: Two functions with the same name both work.
+ Given I see the app
+ Then I see the page is View A
+ When I click the button First
+ When I wait for a second
+ Then I see the result is {"a":"First Value","b":1}
+ When I click the button Second
+ When I wait for a second
+ Then I see the result is {"a":"Second Value","b":2}
+ When I click the button Third
+ When I wait for a second
+ Then I see the result is Third value.
\ No newline at end of file
diff --git a/examples/lazy_routes/e2e/features/shared_chunks.feature b/examples/lazy_routes/e2e/features/shared_chunks.feature
new file mode 100644
index 000000000..782581581
--- /dev/null
+++ b/examples/lazy_routes/e2e/features/shared_chunks.feature
@@ -0,0 +1,9 @@
+@shared_chunks
+Feature: Shared code splitting works correctly
+
+ Scenario: Two functions using same serde code both work.
+ Given I see the app
+ Then I see the page is View A
+ When I click the button First
+ When I wait for a second
+ Then I see the result is {"a":"First Value","b":1}
\ No newline at end of file
diff --git a/examples/lazy_routes/e2e/tests/app_suite.rs b/examples/lazy_routes/e2e/tests/app_suite.rs
new file mode 100644
index 000000000..63e0dac14
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/app_suite.rs
@@ -0,0 +1,30 @@
+mod fixtures;
+
+use anyhow::Result;
+use cucumber::World;
+use fixtures::world::AppWorld;
+use std::{ffi::OsStr, fs::read_dir};
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ // Normally the below is done, but it's now gotten to the point of
+ // having a sufficient number of tests where the resource contention
+ // of the concurrently running browsers will cause failures on CI.
+ // AppWorld::cucumber()
+ // .fail_on_skipped()
+ // .run_and_exit("./features")
+ // .await;
+
+ // Mitigate the issue by manually stepping through each feature,
+ // rather than letting cucumber glob them and dispatch all at once.
+ for entry in read_dir("./features")? {
+ let path = entry?.path();
+ if path.extension() == Some(OsStr::new("feature")) {
+ AppWorld::cucumber()
+ .fail_on_skipped()
+ .run_and_exit(path)
+ .await;
+ }
+ }
+ Ok(())
+}
diff --git a/examples/lazy_routes/e2e/tests/fixtures/action.rs b/examples/lazy_routes/e2e/tests/fixtures/action.rs
new file mode 100644
index 000000000..fb9f2d869
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/action.rs
@@ -0,0 +1,23 @@
+use super::{find, world::HOST};
+use anyhow::Result;
+use fantoccini::Client;
+use std::result::Result::Ok;
+
+pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
+ let url = format!("{}{}", HOST, path);
+ client.goto(&url).await?;
+
+ Ok(())
+}
+
+pub async fn click_link(client: &Client, text: &str) -> Result<()> {
+ let link = find::link_with_text(&client, &text).await?;
+ link.click().await?;
+ Ok(())
+}
+
+pub async fn click_button(client: &Client, id: &str) -> Result<()> {
+ let btn = find::element_by_id(&client, &id).await?;
+ btn.click().await?;
+ Ok(())
+}
diff --git a/examples/lazy_routes/e2e/tests/fixtures/check.rs b/examples/lazy_routes/e2e/tests/fixtures/check.rs
new file mode 100644
index 000000000..7f5a18303
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/check.rs
@@ -0,0 +1,29 @@
+use crate::fixtures::find;
+use anyhow::{Ok, Result};
+use fantoccini::Client;
+use pretty_assertions::assert_eq;
+
+pub async fn page_name_is(client: &Client, expected_text: &str) -> Result<()> {
+ let actual = find::text_at_id(client, "page").await?;
+ assert_eq!(&actual, expected_text);
+ Ok(())
+}
+
+pub async fn result_is(client: &Client, expected_text: &str) -> Result<()> {
+ let actual = find::text_at_id(client, "result").await?;
+ assert_eq!(&actual, expected_text);
+ Ok(())
+}
+
+pub async fn navigating_appears(client: &Client) -> Result<()> {
+ let actual = find::text_at_id(client, "navigating").await?;
+ assert_eq!(&actual, "Navigating...");
+ Ok(())
+}
+
+pub async fn element_exists(client: &Client, id: &str) -> Result<()> {
+ find::element_by_id(client, id)
+ .await
+ .expect(&format!("could not find element with id `{id}`"));
+ Ok(())
+}
diff --git a/examples/lazy_routes/e2e/tests/fixtures/find.rs b/examples/lazy_routes/e2e/tests/fixtures/find.rs
new file mode 100644
index 000000000..d93ea56b8
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/find.rs
@@ -0,0 +1,23 @@
+use anyhow::{Ok, Result};
+use fantoccini::{elements::Element, Client, Locator};
+
+pub async fn text_at_id(client: &Client, id: &str) -> Result {
+ let element = element_by_id(client, id)
+ .await
+ .expect(format!("no such element with id `{}`", id).as_str());
+ let text = element.text().await?;
+ Ok(text)
+}
+
+pub async fn link_with_text(client: &Client, text: &str) -> Result {
+ let link = client
+ .wait()
+ .for_element(Locator::LinkText(text))
+ .await
+ .expect(format!("Link not found by `{}`", text).as_str());
+ Ok(link)
+}
+
+pub async fn element_by_id(client: &Client, id: &str) -> Result {
+ Ok(client.wait().for_element(Locator::Id(id)).await?)
+}
diff --git a/examples/lazy_routes/e2e/tests/fixtures/mod.rs b/examples/lazy_routes/e2e/tests/fixtures/mod.rs
new file mode 100644
index 000000000..72b1bd65e
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/mod.rs
@@ -0,0 +1,4 @@
+pub mod action;
+pub mod check;
+pub mod find;
+pub mod world;
diff --git a/examples/lazy_routes/e2e/tests/fixtures/world/action_steps.rs b/examples/lazy_routes/e2e/tests/fixtures/world/action_steps.rs
new file mode 100644
index 000000000..9f0019251
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/world/action_steps.rs
@@ -0,0 +1,68 @@
+use crate::fixtures::{action, world::AppWorld};
+use anyhow::{Ok, Result};
+use cucumber::{gherkin::Step, given, when};
+
+#[given("I see the app")]
+#[when("I open the app")]
+async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
+ let client = &world.client;
+ action::goto_path(client, "").await?;
+
+ Ok(())
+}
+
+#[when(regex = "^I open the app at (.*)$")]
+async fn i_open_the_app_at(world: &mut AppWorld, url: String) -> Result<()> {
+ let client = &world.client;
+ action::goto_path(client, &url).await?;
+
+ Ok(())
+}
+
+#[when(regex = "^I select the link (.*)$")]
+async fn i_select_the_link(world: &mut AppWorld, text: String) -> Result<()> {
+ let client = &world.client;
+ action::click_link(client, &text).await?;
+
+ Ok(())
+}
+
+#[when(regex = "^I click the button (.*)$")]
+async fn i_click_the_button(world: &mut AppWorld, id: String) -> Result<()> {
+ let client = &world.client;
+ action::click_button(client, &id).await?;
+
+ Ok(())
+}
+
+#[when(expr = "I select the following links")]
+async fn i_select_the_following_links(
+ world: &mut AppWorld,
+ step: &Step,
+) -> Result<()> {
+ let client = &world.client;
+
+ if let Some(table) = step.table.as_ref() {
+ for row in table.rows.iter() {
+ action::click_link(client, &row[0]).await?;
+ }
+ }
+
+ Ok(())
+}
+
+#[when("I wait for a second")]
+async fn i_wait_for_a_second(world: &mut AppWorld) -> Result<()> {
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+
+ Ok(())
+}
+
+#[given(regex = "^I (refresh|reload) the (browser|page)$")]
+#[when(regex = "^I (refresh|reload) the (browser|page)$")]
+async fn i_refresh_the_browser(world: &mut AppWorld) -> Result<()> {
+ let client = &world.client;
+ client.refresh().await?;
+
+ Ok(())
+}
diff --git a/examples/lazy_routes/e2e/tests/fixtures/world/check_steps.rs b/examples/lazy_routes/e2e/tests/fixtures/world/check_steps.rs
new file mode 100644
index 000000000..d88e81576
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/world/check_steps.rs
@@ -0,0 +1,31 @@
+use crate::fixtures::{check, world::AppWorld};
+use anyhow::{Ok, Result};
+use cucumber::then;
+
+#[then(regex = r"^I see the navigating indicator")]
+async fn i_see_the_nav(world: &mut AppWorld) -> Result<()> {
+ let client = &world.client;
+ check::navigating_appears(client).await?;
+ Ok(())
+}
+
+#[then(regex = r"^I see the page is (.*)$")]
+async fn i_see_the_page_is(world: &mut AppWorld, text: String) -> Result<()> {
+ let client = &world.client;
+ check::page_name_is(client, &text).await?;
+ Ok(())
+}
+
+#[then(regex = r"^I see the result is (.*)$")]
+async fn i_see_the_result_is(world: &mut AppWorld, text: String) -> Result<()> {
+ let client = &world.client;
+ check::result_is(client, &text).await?;
+ Ok(())
+}
+
+#[then(regex = r"^I see the navbar$")]
+async fn i_see_the_navbar(world: &mut AppWorld) -> Result<()> {
+ let client = &world.client;
+ check::element_exists(client, "nav").await?;
+ Ok(())
+}
diff --git a/examples/lazy_routes/e2e/tests/fixtures/world/mod.rs b/examples/lazy_routes/e2e/tests/fixtures/world/mod.rs
new file mode 100644
index 000000000..c25a92570
--- /dev/null
+++ b/examples/lazy_routes/e2e/tests/fixtures/world/mod.rs
@@ -0,0 +1,39 @@
+pub mod action_steps;
+pub mod check_steps;
+
+use anyhow::Result;
+use cucumber::World;
+use fantoccini::{
+ error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
+};
+
+pub const HOST: &str = "http://127.0.0.1:3000";
+
+#[derive(Debug, World)]
+#[world(init = Self::new)]
+pub struct AppWorld {
+ pub client: Client,
+}
+
+impl AppWorld {
+ async fn new() -> Result {
+ let webdriver_client = build_client().await?;
+
+ Ok(Self {
+ client: webdriver_client,
+ })
+ }
+}
+
+async fn build_client() -> Result {
+ let mut cap = Capabilities::new();
+ let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
+ cap.insert("goog:chromeOptions".to_string(), arg);
+
+ let client = ClientBuilder::native()
+ .capabilities(cap)
+ .connect("http://localhost:4444")
+ .await?;
+
+ Ok(client)
+}
diff --git a/examples/lazy_routes/src/app.rs b/examples/lazy_routes/src/app.rs
new file mode 100644
index 000000000..7e022ff81
--- /dev/null
+++ b/examples/lazy_routes/src/app.rs
@@ -0,0 +1,352 @@
+use leptos::{prelude::*, task::spawn_local};
+use leptos_router::{
+ components::{Outlet, ParentRoute, Route, Router, Routes},
+ lazy_route, Lazy, LazyRoute, StaticSegment,
+};
+use serde::{Deserialize, Serialize};
+
+pub fn shell(options: LeptosOptions) -> impl IntoView {
+ view! {
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
+
+#[component]
+pub fn App() -> impl IntoView {
+ let count = RwSignal::new(0);
+ provide_context(count);
+ let (is_routing, set_is_routing) = signal(false);
+
+ view! {
+
+ "A" " | "
+ "B" " | "
+ "C" " | "
+ "D"
+
+ {move || is_routing.get().then_some("Navigating...")}
+
+
+
+
+
+
+ ::new()}/>
+ // you can nest lazy routes, and there data and views will all load concurrently
+ ::new()}>
+ ::new()}/>
+
+
+
+ }
+}
+
+// View A: A plain old synchronous route, just like they all currently work. The WASM binary code
+// for this is shipped as part of the main bundle. Any data-loading code (like resources that run
+// in the body of the component) will be shipped as part of the main bundle.
+
+#[component]
+pub fn ViewA() -> impl IntoView {
+ leptos::logging::log!("View A");
+ let result = RwSignal::new("Click a button to see the result".to_string());
+
+ view! {
+ "View A"
+ {result}
+ "First"
+ "Second"
+ // test to make sure duplicate names in different scopes can be used
+ String {
+ "Third value.".to_string()
+ }
+
+ spawn_local(async move {
+ result.set(second_value().await);
+ });
+ }>"Third"
+ }
+}
+
+// View B: lazy-loaded route with lazy-loaded data
+#[derive(Debug, Clone, Deserialize)]
+pub struct Comment {
+ #[serde(rename = "postId")]
+ post_id: usize,
+ id: usize,
+ name: String,
+ email: String,
+ body: String,
+}
+
+#[lazy]
+fn deserialize_comments(data: &str) -> Vec {
+ serde_json::from_str(data).unwrap()
+}
+
+#[component]
+pub fn ViewB() -> impl IntoView {
+ let data = LocalResource::new(|| async move {
+ let preload = deserialize_comments("[]");
+ let (_, data) = futures::future::join(preload, async {
+ gloo_timers::future::TimeoutFuture::new(500).await;
+
+ r#"
+ [
+ {
+ "postId": 1,
+ "id": 1,
+ "name": "id labore ex et quam laborum",
+ "email": "Eliseo@gardner.biz",
+ "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
+ },
+ {
+ "postId": 1,
+ "id": 2,
+ "name": "quo vero reiciendis velit similique earum",
+ "email": "Jayne_Kuhic@sydney.com",
+ "body": "est natus enim nihil est dolore omnis voluptatem numquam\net omnis occaecati quod ullam at\nvoluptatem error expedita pariatur\nnihil sint nostrum voluptatem reiciendis et"
+ },
+ {
+ "postId": 1,
+ "id": 3,
+ "name": "odio adipisci rerum aut animi",
+ "email": "Nikita@garfield.biz",
+ "body": "quia molestiae reprehenderit quasi aspernatur\naut expedita occaecati aliquam eveniet laudantium\nomnis quibusdam delectus saepe quia accusamus maiores nam est\ncum et ducimus et vero voluptates excepturi deleniti ratione"
+ }
+ ]
+ "#
+ })
+ .await;
+ deserialize_comments(data).await
+ });
+ view! {
+ "View B"
+ "Loading..."
}>
+
+ {move || Suspend::new(async move {
+ let items = data.await;
+ items.into_iter()
+ .map(|comment| view! {
+
+ {comment.name} " (by " {comment.email} ")"
+ {comment.body}
+
+ })
+ .collect_view()
+ })}
+
+
+ }
+ .into_any()
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Album {
+ #[serde(rename = "userId")]
+ user_id: usize,
+ id: usize,
+ title: String,
+}
+
+// View C: a lazy view, and some data, loaded in parallel when we navigate to /c.
+#[derive(Clone)]
+pub struct ViewC {
+ data: LocalResource>,
+}
+
+// Lazy-loaded routes need to implement the LazyRoute trait. They define a "route data" struct,
+// which is created with `::data()`, and then a separate view function which is lazily loaded.
+//
+// This is important because it allows us to concurrently 1) load the route data, and 2) lazily
+// load the component, rather than creating a "waterfall" where we can't start loading the route
+// data until we've received the view.
+//
+// The `#[lazy_route]` macro makes `view` into a lazy-loaded inner function, replacing `self` with
+// `this`.
+#[lazy_route]
+impl LazyRoute for ViewC {
+ fn data() -> Self {
+ // the data method itself is synchronous: it typically creates things like Resources,
+ // which are created synchronously but spawn an async data-loading task
+ // if you want further code-splitting, however, you can create a lazy function to load the data!
+ #[lazy]
+ async fn lazy_data() -> Vec {
+ gloo_timers::future::TimeoutFuture::new(250).await;
+ vec![
+ Album {
+ user_id: 1,
+ id: 1,
+ title: "quidem molestiae enim".into(),
+ },
+ Album {
+ user_id: 1,
+ id: 2,
+ title: "sunt qui excepturi placeat culpa".into(),
+ },
+ Album {
+ user_id: 1,
+ id: 3,
+ title: "omnis laborum odio".into(),
+ },
+ ]
+ }
+
+ Self {
+ data: LocalResource::new(lazy_data),
+ }
+ }
+
+ fn view(this: Self) -> AnyView {
+ let albums = move || {
+ Suspend::new(async move {
+ this.data
+ .await
+ .into_iter()
+ .map(|album| {
+ view! {
+
+ {album.title}
+
+ }
+ })
+ .collect::>()
+ })
+ };
+ view! {
+ "View C"
+
+ "Loading..." }>
+
+
+ }
+ .into_any()
+ }
+}
+
+// When two functions have shared code, that shared code will be split out automatically
+// into an additional file. For example, the shared serde code here will be split into a single file,
+// and then loaded lazily once when the first of the two functions is called
+
+#[lazy]
+pub fn first_value() -> String {
+ #[derive(Serialize)]
+ struct FirstValue {
+ a: String,
+ b: i32,
+ }
+
+ serde_json::to_string(&FirstValue {
+ a: "First Value".into(),
+ b: 1,
+ })
+ .unwrap()
+}
+
+#[lazy]
+pub fn second_value() -> String {
+ #[derive(Serialize)]
+ struct SecondValue {
+ a: String,
+ b: i32,
+ }
+
+ serde_json::to_string(&SecondValue {
+ a: "Second Value".into(),
+ b: 2,
+ })
+ .unwrap()
+}
+
+struct ViewD {
+ data: Resource, ServerFnError>>,
+}
+
+#[lazy_route]
+impl LazyRoute for ViewD {
+ fn data() -> Self {
+ Self {
+ data: Resource::new(|| (), |_| d_data()),
+ }
+ }
+
+ fn view(this: Self) -> AnyView {
+ let items = move || {
+ Suspend::new(async move {
+ this.data
+ .await
+ .unwrap_or_default()
+ .into_iter()
+ .map(|item| view! { {item} })
+ .collect::>()
+ })
+ };
+ view! {
+ "View D"
+
+ "Loading..." }>
+
+
+
+ }
+ .into_any()
+ }
+}
+
+#[server]
+async fn d_data() -> Result, ServerFnError> {
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ Ok(vec![1, 1, 2, 3, 5, 8, 13])
+}
+
+struct ViewE {
+ data: Resource, ServerFnError>>,
+}
+
+#[lazy_route]
+impl LazyRoute for ViewE {
+ fn data() -> Self {
+ Self {
+ data: Resource::new(|| (), |_| e_data()),
+ }
+ }
+
+ fn view(this: Self) -> AnyView {
+ let items = move || {
+ Suspend::new(async move {
+ this.data
+ .await
+ .unwrap_or_default()
+ .into_iter()
+ .map(|item| view! { {item} })
+ .collect::>()
+ })
+ };
+ view! {
+ "View E"
+
+ "Loading..." }>
+
+
+ }
+ .into_any()
+ }
+}
+
+#[server]
+async fn e_data() -> Result, ServerFnError> {
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ Ok(vec!["foo".into(), "bar".into(), "baz".into()])
+}
diff --git a/examples/lazy_routes/src/lib.rs b/examples/lazy_routes/src/lib.rs
new file mode 100644
index 000000000..9abf4f990
--- /dev/null
+++ b/examples/lazy_routes/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod app;
+
+#[cfg(feature = "hydrate")]
+#[wasm_bindgen::prelude::wasm_bindgen]
+pub fn hydrate() {
+ use app::*;
+ console_error_panic_hook::set_once();
+ leptos::mount::hydrate_lazy(App);
+}
diff --git a/examples/lazy_routes/src/main.rs b/examples/lazy_routes/src/main.rs
new file mode 100644
index 000000000..d3e0ad940
--- /dev/null
+++ b/examples/lazy_routes/src/main.rs
@@ -0,0 +1,37 @@
+#[cfg(feature = "ssr")]
+#[tokio::main]
+async fn main() {
+ use axum::Router;
+ use lazy_routes::app::{shell, App};
+ use leptos::prelude::*;
+ use leptos_axum::{generate_route_list, LeptosRoutes};
+
+ let conf = get_configuration(None).unwrap();
+ let addr = conf.leptos_options.site_addr;
+ let leptos_options = conf.leptos_options;
+ // Generate the list of routes in your Leptos App
+ let routes = generate_route_list(App);
+
+ let app = Router::new()
+ .leptos_routes(&leptos_options, routes, {
+ let leptos_options = leptos_options.clone();
+ move || shell(leptos_options.clone())
+ })
+ .fallback(leptos_axum::file_and_error_handler(shell))
+ .with_state(leptos_options);
+
+ // run our app with hyper
+ // `axum::Server` is a re-export of `hyper::Server`
+ println!("listening on http://{}", &addr);
+ let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
+ axum::serve(listener, app.into_make_service())
+ .await
+ .unwrap();
+}
+
+#[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
+}
diff --git a/examples/lazy_routes/style/main.scss b/examples/lazy_routes/style/main.scss
new file mode 100644
index 000000000..853ac29a7
--- /dev/null
+++ b/examples/lazy_routes/style/main.scss
@@ -0,0 +1,3 @@
+body {
+ font-family: sans-serif;
+}
diff --git a/integrations/utils/src/lib.rs b/integrations/utils/src/lib.rs
index 311f5d530..979ffe9be 100644
--- a/integrations/utils/src/lib.rs
+++ b/integrations/utils/src/lib.rs
@@ -3,12 +3,14 @@
use futures::{stream::once, Stream, StreamExt};
use hydration_context::{SharedContext, SsrSharedContext};
use leptos::{
+ context::provide_context,
nonce::use_nonce,
+ prelude::ReadValue,
reactive::owner::{Owner, Sandboxed},
- IntoView,
+ IntoView, PrefetchLazyFn, WasmSplitManifest,
};
use leptos_config::LeptosOptions;
-use leptos_meta::ServerMetaContextOutput;
+use leptos_meta::{Link, ServerMetaContextOutput};
use std::{future::Future, pin::Pin, sync::Arc};
pub type PinnedStream = Pin + Send>>;
@@ -41,6 +43,8 @@ pub trait ExtendResponse: Sized {
IV: IntoView + 'static,
{
async move {
+ let prefetches = PrefetchLazyFn::default();
+
let (owner, stream) = build_response(
app_fn,
additional_context,
@@ -48,6 +52,8 @@ pub trait ExtendResponse: Sized {
supports_ooo,
);
+ owner.with(|| provide_context(prefetches.clone()));
+
let sc = owner.shared_context().unwrap();
let stream = stream.await.ready_chunks(32).map(|n| n.join(""));
@@ -56,6 +62,40 @@ pub trait ExtendResponse: Sized {
pending.await;
}
+ if !prefetches.0.read_value().is_empty() {
+ use leptos::prelude::*;
+
+ let nonce =
+ use_nonce().map(|n| n.to_string()).unwrap_or_default();
+ if let Some(manifest) = use_context::() {
+ let (pkg_path, manifest) = &*manifest.0.read_value();
+ let prefetches = prefetches.0.read_value();
+
+ let all_prefetches = prefetches.iter().flat_map(|key| {
+ manifest.get(*key).into_iter().flatten()
+ });
+
+ for module in all_prefetches {
+ // to_html() on leptos_meta components registers them with the meta context,
+ // rather than returning HTML directly
+ _ = view! {
+
+ }
+ .to_html();
+ }
+ _ = view! {
+
+ }
+ .to_html();
+ }
+ }
+
let mut stream = Box::pin(
meta_context.inject_meta_context(stream).await.then({
let sc = Arc::clone(&sc);
diff --git a/leptos/Cargo.toml b/leptos/Cargo.toml
index ef9e7ccae..4e5435a2e 100644
--- a/leptos/Cargo.toml
+++ b/leptos/Cargo.toml
@@ -24,14 +24,14 @@ leptos_hot_reload = { workspace = true }
leptos_macro = { workspace = true }
leptos_server = { workspace = true, features = ["tachys"] }
leptos_config = { workspace = true }
-leptos-spin-macro = { optional = true , workspace = true, default-features = true }
+leptos-spin-macro = { optional = true, workspace = true, default-features = true }
oco_ref = { workspace = true }
or_poisoned = { workspace = true }
paste = { workspace = true, default-features = true }
-rand = { optional = true , workspace = true, default-features = true }
+rand = { optional = true, workspace = true, default-features = true }
# NOTE: While not used directly, `getrandom`'s `wasm_js` feature is needed when `rand` is used on WASM to
# avoid a compilation error
-getrandom = { optional = true , workspace = true, default-features = true }
+getrandom = { optional = true, workspace = true, default-features = true }
reactive_graph = { workspace = true, features = ["serde"] }
rustc-hash = { workspace = true, default-features = true }
tachys = { workspace = true, features = [
@@ -44,7 +44,7 @@ tracing = { optional = true, workspace = true, default-features = true }
typed-builder = { workspace = true, default-features = true }
typed-builder-macro = { workspace = true, default-features = true }
serde = { workspace = true, default-features = true }
-serde_json = { optional = true, workspace = true, default-features = true }
+serde_json = { workspace = true, default-features = true }
server_fn = { workspace = true, features = ["form-redirects", "browser"] }
web-sys = { features = [
"ShadowRoot",
@@ -52,10 +52,12 @@ web-sys = { features = [
"ShadowRootMode",
], workspace = true, default-features = true }
wasm-bindgen = { workspace = true, default-features = true }
+wasm-bindgen-futures = { workspace = true, default-features = true }
serde_qs = { workspace = true, default-features = true }
slotmap = { workspace = true, default-features = true }
futures = { workspace = true, default-features = true }
send_wrapper = { workspace = true, default-features = true }
+wasm_split_helpers.workspace = true
[features]
hydration = [
@@ -93,7 +95,7 @@ tracing = [
]
nonce = ["base64", "rand", "dep:getrandom"]
spin = ["leptos-spin-macro"]
-islands = ["leptos_macro/islands", "dep:serde_json"]
+islands = ["leptos_macro/islands"]
trace-component-props = [
"leptos_macro/trace-component-props",
"leptos_dom/trace-component-props",
@@ -102,7 +104,10 @@ delegation = ["tachys/delegation"]
islands-router = ["tachys/mark_branches"]
[dev-dependencies]
-tokio = { features = ["rt-multi-thread", "macros"] , workspace = true, default-features = true }
+tokio = { features = [
+ "rt-multi-thread",
+ "macros",
+], workspace = true, default-features = true }
tokio-test = { workspace = true, default-features = true }
any_spawner = { workspace = true, features = ["futures-executor", "tokio"] }
diff --git a/leptos/src/attribute_interceptor.rs b/leptos/src/attribute_interceptor.rs
index 22698a495..c7dfdf918 100644
--- a/leptos/src/attribute_interceptor.rs
+++ b/leptos/src/attribute_interceptor.rs
@@ -157,6 +157,14 @@ impl RenderHtml
self.children.hydrate::(cursor, position)
}
+ async fn hydrate_async(
+ self,
+ cursor: &leptos::tachys::hydration::Cursor,
+ position: &leptos::tachys::view::PositionState,
+ ) -> Self::State {
+ self.children.hydrate_async(cursor, position).await
+ }
+
fn into_owned(self) -> Self::Owned {
AttributeInterceptorInner {
children_builder: self.children_builder,
diff --git a/leptos/src/error_boundary.rs b/leptos/src/error_boundary.rs
index 6645ef791..f58a86d06 100644
--- a/leptos/src/error_boundary.rs
+++ b/leptos/src/error_boundary.rs
@@ -2,6 +2,7 @@ use crate::{children::TypedChildren, IntoView};
use futures::{channel::oneshot, future::join_all};
use hydration_context::{SerializedDataId, SharedContext};
use leptos_macro::component;
+use or_poisoned::OrPoisoned;
use reactive_graph::{
computed::ArcMemo,
effect::RenderEffect,
@@ -10,7 +11,12 @@ use reactive_graph::{
traits::{Get, Update, With, WithUntracked, WriteValue},
};
use rustc_hash::FxHashMap;
-use std::{collections::VecDeque, fmt::Debug, mem, sync::Arc};
+use std::{
+ collections::VecDeque,
+ fmt::Debug,
+ mem,
+ sync::{Arc, Mutex},
+};
use tachys::{
html::attribute::{any_attribute::AnyAttribute, Attribute},
hydration::Cursor,
@@ -508,6 +514,79 @@ where
)
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let mut children = Some(self.children);
+ let hook = Arc::clone(&self.hook);
+ let cursor = cursor.to_owned();
+ let position = position.to_owned();
+
+ let fallback_fn = Arc::new(Mutex::new(self.fallback));
+ let initial = {
+ let errors_empty = self.errors_empty.clone();
+ let errors = self.errors.clone();
+ let fallback_fn = Arc::clone(&fallback_fn);
+ async move {
+ let children = children.take().unwrap();
+ let (children, fallback) = if errors_empty.get() {
+ (children.hydrate_async(&cursor, &position).await, None)
+ } else {
+ let children = children.build();
+ let fallback =
+ (fallback_fn.lock().or_poisoned())(errors.clone());
+ let fallback =
+ fallback.hydrate_async(&cursor, &position).await;
+ (children, Some(fallback))
+ };
+
+ ErrorBoundaryViewState { children, fallback }
+ }
+ };
+
+ RenderEffect::new_with_async_value(
+ move |prev: Option<
+ ErrorBoundaryViewState,
+ >| {
+ let _hook = throw_error::set_error_hook(Arc::clone(&hook));
+ if let Some(mut state) = prev {
+ match (self.errors_empty.get(), &mut state.fallback) {
+ // no errors, and was showing fallback
+ (true, Some(fallback)) => {
+ fallback.insert_before_this(&mut state.children);
+ state.fallback.unmount();
+ state.fallback = None;
+ }
+ // yes errors, and was showing children
+ (false, None) => {
+ state.fallback = Some(
+ (fallback_fn.lock().or_poisoned())(
+ self.errors.clone(),
+ )
+ .build(),
+ );
+ state
+ .children
+ .insert_before_this(&mut state.fallback);
+ state.children.unmount();
+ }
+ // either there were no errors, and we were already showing the children
+ // or there are errors, but we were already showing the fallback
+ // in either case, rebuilding doesn't require us to do anything
+ _ => {}
+ }
+ state
+ } else {
+ unreachable!()
+ }
+ },
+ initial,
+ )
+ .await
+ }
+
fn into_owned(self) -> Self::Owned {
self
}
diff --git a/leptos/src/hydration/island_script.js b/leptos/src/hydration/island_script.js
index 8accda970..e753d0703 100644
--- a/leptos/src/hydration/island_script.js
+++ b/leptos/src/hydration/island_script.js
@@ -8,46 +8,49 @@
c();
}
}
- function hydrateIslands(rootNode, mod) {
- function traverse(node) {
+ async function hydrateIslands(rootNode, mod) {
+ async function traverse(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
if(tag === 'leptos-island') {
const children = [];
const id = node.dataset.component || null;
- hydrateIsland(node, id, mod);
+ await hydrateIsland(node, id, mod);
for(const child of node.children) {
- traverse(child, children);
+ await traverse(child, children);
}
} else {
if (tag === 'leptos-children') {
MOST_RECENT_CHILDREN_CB.push(node.$$on_hydrate);
for(const child of node.children) {
- traverse(child);
+ await traverse(child);
};
// un-set the "most recent children"
MOST_RECENT_CHILDREN_CB.pop();
} else {
for(const child of node.children) {
- traverse(child);
+ await traverse(child);
};
}
}
}
}
- traverse(rootNode);
+ await traverse(rootNode);
}
- function hydrateIsland(el, id, mod) {
+ async function hydrateIsland(el, id, mod) {
const islandFn = mod[id];
if (islandFn) {
const children_cb = MOST_RECENT_CHILDREN_CB[MOST_RECENT_CHILDREN_CB.length-1];
if (children_cb) {
children_cb();
}
- islandFn(el);
+ const res = islandFn(el);
+ if (res && res.then) {
+ await res;
+ }
} else {
console.warn(`Could not find WASM function for the island ${id}.`);
}
diff --git a/leptos/src/hydration/mod.rs b/leptos/src/hydration/mod.rs
index 90b5d47fb..60721459f 100644
--- a/leptos/src/hydration/mod.rs
+++ b/leptos/src/hydration/mod.rs
@@ -1,8 +1,9 @@
#![allow(clippy::needless_lifetimes)]
-use crate::prelude::*;
+use crate::{prelude::*, WasmSplitManifest};
use leptos_config::LeptosOptions;
use leptos_macro::{component, view};
+use std::{path::PathBuf, sync::OnceLock};
/// Inserts auto-reloading code used in `cargo-leptos`.
///
@@ -58,6 +59,29 @@ pub fn HydrationScripts(
#[prop(optional, into)]
root: Option,
) -> impl IntoView {
+ static SPLIT_MANIFEST: OnceLock> =
+ OnceLock::new();
+
+ if let Some(splits) = SPLIT_MANIFEST.get_or_init(|| {
+ let root = root.clone().unwrap_or_default();
+
+ let site_dir = &options.site_root;
+ let pkg_dir = &options.site_pkg_dir;
+ let path = PathBuf::from(site_dir.to_string());
+ let path = path
+ .join(pkg_dir.to_string())
+ .join("__wasm_split_manifest.json");
+ let file = std::fs::read_to_string(path).ok()?;
+ let manifest = WasmSplitManifest(ArcStoredValue::new((
+ format!("{root}/{pkg_dir}"),
+ serde_json::from_str(&file).expect("could not read manifest file"),
+ )));
+
+ Some(manifest)
+ }) {
+ provide_context(splits.clone());
+ }
+
let mut js_file_name = options.output_name.to_string();
let mut wasm_file_name = options.output_name.to_string();
if options.hash_files {
@@ -112,7 +136,7 @@ pub fn HydrationScripts(
let root = root.unwrap_or_default();
view! {
-
+
RenderHtml for View {
self.inner.hydrate::(cursor, position)
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ self.inner.hydrate_async(cursor, position).await
+ }
+
fn into_owned(self) -> Self::Owned {
View {
inner: self.inner.into_owned(),
diff --git a/leptos/src/lib.rs b/leptos/src/lib.rs
index 44a276878..e0913d163 100644
--- a/leptos/src/lib.rs
+++ b/leptos/src/lib.rs
@@ -335,7 +335,6 @@ pub mod task {
#[cfg(feature = "islands")]
#[doc(hidden)]
pub use serde;
-#[cfg(feature = "islands")]
#[doc(hidden)]
pub use serde_json;
#[cfg(feature = "tracing")]
@@ -343,5 +342,38 @@ pub use serde_json;
pub use tracing;
#[doc(hidden)]
pub use wasm_bindgen;
+pub use wasm_split_helpers;
#[doc(hidden)]
pub use web_sys;
+
+#[doc(hidden)]
+pub mod __reexports {
+ pub use wasm_bindgen_futures;
+}
+
+#[doc(hidden)]
+#[derive(Clone, Debug, Default)]
+pub struct PrefetchLazyFn(
+ pub reactive_graph::owner::ArcStoredValue<
+ std::collections::HashSet<&'static str>,
+ >,
+);
+
+#[doc(hidden)]
+pub fn prefetch_lazy_fn_on_server(id: &'static str) {
+ use crate::context::use_context;
+ use reactive_graph::traits::WriteValue;
+
+ if let Some(prefetches) = use_context::() {
+ prefetches.0.write_value().insert(id);
+ }
+}
+
+#[doc(hidden)]
+#[derive(Clone, Debug, Default)]
+pub struct WasmSplitManifest(
+ pub reactive_graph::owner::ArcStoredValue<(
+ String,
+ std::collections::HashMap>,
+ )>,
+);
diff --git a/leptos/src/mount.rs b/leptos/src/mount.rs
index 526bd8d6a..9f441bde5 100644
--- a/leptos/src/mount.rs
+++ b/leptos/src/mount.rs
@@ -29,6 +29,25 @@ where
owner.forget();
}
+#[cfg(feature = "hydrate")]
+/// Hydrates the app described by the provided function, starting at ``, with support
+/// for lazy-loaded routes and components.
+pub fn hydrate_lazy(f: F)
+where
+ F: FnOnce() -> N + 'static,
+ N: IntoView,
+{
+ // use wasm-bindgen-futures to drive the reactive system
+ // we ignore the return value because an Err here just means the wasm-bindgen executor is
+ // already initialized, which is not an issue
+ _ = Executor::init_wasm_bindgen();
+
+ crate::task::spawn_local(async move {
+ let owner = hydrate_from_async(body(), f).await;
+ owner.forget();
+ })
+}
+
#[cfg(debug_assertions)]
thread_local! {
static FIRST_CALL: Cell = const { Cell::new(true) };
@@ -83,6 +102,65 @@ where
UnmountHandle { owner, mountable }
}
+#[cfg(feature = "hydrate")]
+/// Runs the provided closure and mounts the result to the provided element.
+pub async fn hydrate_from_async(
+ parent: HtmlElement,
+ f: F,
+) -> UnmountHandle
+where
+ F: FnOnce() -> N + 'static,
+ N: IntoView,
+{
+ use hydration_context::HydrateSharedContext;
+ use std::sync::Arc;
+
+ // use wasm-bindgen-futures to drive the reactive system
+ // we ignore the return value because an Err here just means the wasm-bindgen executor is
+ // already initialized, which is not an issue
+ _ = Executor::init_wasm_bindgen();
+
+ #[cfg(debug_assertions)]
+ {
+ if !cfg!(feature = "hydrate") && FIRST_CALL.get() {
+ logging::warn!(
+ "It seems like you're trying to use Leptos in hydration mode, \
+ but the `hydrate` feature is not enabled on the `leptos` \
+ crate. Add `features = [\"hydrate\"]` to your Cargo.toml for \
+ the crate to work properly.\n\nNote that hydration and \
+ client-side rendering now use separate functions from \
+ leptos::mount: you are calling a hydration function."
+ );
+ }
+ FIRST_CALL.set(false);
+ }
+
+ // create a new reactive owner and use it as the root node to run the app
+ let owner = Owner::new_root(Some(Arc::new(HydrateSharedContext::new())));
+ let mountable = owner
+ .with(move || {
+ use reactive_graph::computed::ScopedFuture;
+
+ ScopedFuture::new(async move {
+ let view = f().into_view();
+ view.hydrate_async(
+ &Cursor::new(parent.unchecked_into()),
+ &PositionState::default(),
+ )
+ .await
+ })
+ })
+ .await;
+
+ if let Some(sc) = Owner::current_shared_context() {
+ sc.hydration_complete();
+ }
+
+ // returns a handle that owns the owner
+ // when this is dropped, it will clean up the reactive system and unmount the view
+ UnmountHandle { owner, mountable }
+}
+
/// Runs the provided closure and mounts the result to the ``.
pub fn mount_to_body(f: F)
where
diff --git a/leptos_hot_reload/src/patch.js b/leptos_hot_reload/src/patch.js
index f1b6c481b..c8033f2b1 100644
--- a/leptos_hot_reload/src/patch.js
+++ b/leptos_hot_reload/src/patch.js
@@ -1,3 +1,4 @@
+console.log("[HOT RELOADING] Connected to server.");
function patch(json) {
try {
const views = JSON.parse(json);
diff --git a/leptos_macro/src/component.rs b/leptos_macro/src/component.rs
index 3f793b4b0..e485eeb43 100644
--- a/leptos_macro/src/component.rs
+++ b/leptos_macro/src/component.rs
@@ -19,6 +19,7 @@ use syn::{
pub struct Model {
is_transparent: bool,
+ is_lazy: bool,
island: Option,
docs: Docs,
unknown_attrs: UnknownAttrs,
@@ -66,6 +67,7 @@ impl Parse for Model {
Ok(Self {
is_transparent: false,
+ is_lazy: false,
island: None,
docs,
unknown_attrs,
@@ -140,6 +142,7 @@ impl ToTokens for Model {
fn to_tokens(&self, tokens: &mut TokenStream) {
let Self {
is_transparent,
+ is_lazy,
island,
docs,
unknown_attrs,
@@ -530,15 +533,41 @@ impl ToTokens for Model {
};
let hydrate_fn_name = hydrate_fn_name.as_ref().unwrap();
- quote! {
- #[::leptos::wasm_bindgen::prelude::wasm_bindgen(wasm_bindgen = ::leptos::wasm_bindgen)]
- #[allow(non_snake_case)]
- pub fn #hydrate_fn_name(el: ::leptos::web_sys::HtmlElement) {
- #deserialize_island_props
- let island = #name(#island_props);
- let state = island.hydrate_from_position::(&el, ::leptos::tachys::view::Position::Current);
- // TODO better cleanup
- std::mem::forget(state);
+
+ let hydrate_fn_inner = quote! {
+ #deserialize_island_props
+ let island = #name(#island_props);
+ let state = island.hydrate_from_position::(&el, ::leptos::tachys::view::Position::Current);
+ // TODO better cleanup
+ std::mem::forget(state);
+ };
+ if *is_lazy {
+ let outer_name =
+ Ident::new(&format!("{name}_loader"), name.span());
+
+ quote! {
+ #[::leptos::prelude::lazy]
+ #[allow(non_snake_case)]
+ async fn #outer_name (el: ::leptos::web_sys::HtmlElement) {
+ #hydrate_fn_inner
+ }
+
+ #[::leptos::wasm_bindgen::prelude::wasm_bindgen(
+ wasm_bindgen = ::leptos::wasm_bindgen,
+ wasm_bindgen_futures = ::leptos::__reexports::wasm_bindgen_futures
+ )]
+ #[allow(non_snake_case)]
+ pub async fn #hydrate_fn_name(el: ::leptos::web_sys::HtmlElement) {
+ #outer_name(el).await
+ }
+ }
+ } else {
+ quote! {
+ #[::leptos::wasm_bindgen::prelude::wasm_bindgen(wasm_bindgen = ::leptos::wasm_bindgen)]
+ #[allow(non_snake_case)]
+ pub fn #hydrate_fn_name(el: ::leptos::web_sys::HtmlElement) {
+ #hydrate_fn_inner
+ }
}
}
} else {
@@ -610,6 +639,13 @@ impl Model {
self
}
+ #[allow(clippy::wrong_self_convention)]
+ pub fn is_lazy(mut self, is_lazy: bool) -> Self {
+ self.is_lazy = is_lazy;
+
+ self
+ }
+
#[allow(clippy::wrong_self_convention)]
pub fn with_island(mut self, island: Option) -> Self {
self.island = island;
diff --git a/leptos_macro/src/lazy.rs b/leptos_macro/src/lazy.rs
index 65fc2bce9..deb078c76 100644
--- a/leptos_macro/src/lazy.rs
+++ b/leptos_macro/src/lazy.rs
@@ -3,30 +3,72 @@ use proc_macro::TokenStream;
use proc_macro2::Ident;
use proc_macro_error2::abort;
use quote::quote;
-use syn::{spanned::Spanned, ItemFn};
+use std::{
+ hash::{DefaultHasher, Hash, Hasher},
+ mem,
+};
+use syn::{parse_macro_input, ItemFn};
-pub fn lazy_impl(
- _args: proc_macro::TokenStream,
- s: TokenStream,
-) -> TokenStream {
- let fun = syn::parse::(s).unwrap_or_else(|e| {
+pub fn lazy_impl(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
+ let name = if !args.is_empty() {
+ Some(parse_macro_input!(args as syn::Ident))
+ } else {
+ None
+ };
+
+ let mut fun = syn::parse::(s).unwrap_or_else(|e| {
abort!(e.span(), "`lazy` can only be used on a function")
});
- if fun.sig.asyncness.is_none() {
- abort!(
- fun.sig.asyncness.span(),
- "`lazy` can only be used on an async function"
+
+ let was_async = fun.sig.asyncness.is_some();
+
+ let converted_name = name.unwrap_or_else(|| {
+ Ident::new(
+ &fun.sig.ident.to_string().to_case(Case::Snake),
+ fun.sig.ident.span(),
)
- }
+ });
- let converted_name = Ident::new(
- &fun.sig.ident.to_string().to_case(Case::Snake),
- fun.sig.ident.span(),
- );
+ let (unique_name, unique_name_str) = {
+ let span = proc_macro::Span::call_site();
+ let location = (span.line(), span.start().column(), span.file());
- quote! {
- #[cfg_attr(feature = "split", wasm_split::wasm_split(#converted_name))]
- #fun
+ let mut hasher = DefaultHasher::new();
+ location.hash(&mut hasher);
+ let hash = hasher.finish();
+
+ let unique_name_str = format!("{converted_name}_{hash}");
+
+ (
+ Ident::new(&unique_name_str, converted_name.span()),
+ unique_name_str,
+ )
+ };
+
+ let is_wasm = cfg!(feature = "csr") || cfg!(feature = "hydrate");
+ if is_wasm {
+ quote! {
+ #[::leptos::wasm_split_helpers::wasm_split(#unique_name)]
+ #fun
+ }
+ } else {
+ if !was_async {
+ fun.sig.asyncness = Some(Default::default());
+ }
+
+ let statements = &mut fun.block.stmts;
+ let old_statements = mem::take(statements);
+ statements.push(
+ syn::parse(
+ quote! {
+ ::leptos::prefetch_lazy_fn_on_server(#unique_name_str);
+ }
+ .into(),
+ )
+ .unwrap(),
+ );
+ statements.extend(old_statements);
+ quote! { #fun }
}
.into()
}
diff --git a/leptos_macro/src/lib.rs b/leptos_macro/src/lib.rs
index 2d9d6e81f..f7b953254 100644
--- a/leptos_macro/src/lib.rs
+++ b/leptos_macro/src/lib.rs
@@ -576,7 +576,7 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
false
};
- component_macro(s, is_transparent, None)
+ component_macro(s, is_transparent, false, None)
}
/// Defines a component as an interactive island when you are using the
@@ -653,36 +653,37 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
#[proc_macro_error2::proc_macro_error]
#[proc_macro_attribute]
pub fn island(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
- let is_transparent = if !args.is_empty() {
- let transparent = parse_macro_input!(args as syn::Ident);
+ let (is_transparent, is_lazy) = if !args.is_empty() {
+ let arg = parse_macro_input!(args as syn::Ident);
- if transparent != "transparent" {
+ if arg != "transparent" && arg != "lazy" {
abort!(
- transparent,
- "only `transparent` is supported";
- help = "try `#[island(transparent)]` or `#[island]`"
+ arg,
+ "only `transparent` or `lazy` are supported";
+ help = "try `#[island(transparent)]`, `#[island(lazy)]`, or `#[island]`"
);
}
- true
+ (arg == "transparent", arg == "lazy")
} else {
- false
+ (false, false)
};
let island_src = s.to_string();
- component_macro(s, is_transparent, Some(island_src))
+ component_macro(s, is_transparent, is_lazy, Some(island_src))
}
fn component_macro(
s: TokenStream,
is_transparent: bool,
+ is_lazy: bool,
island: Option,
) -> TokenStream {
let mut dummy = syn::parse::(s.clone());
let parse_result = syn::parse::(s);
if let (Ok(ref mut unexpanded), Ok(model)) = (&mut dummy, parse_result) {
- let expanded = model.is_transparent(is_transparent).with_island(island).into_token_stream();
+ let expanded = model.is_transparent(is_transparent).is_lazy(is_lazy).with_island(island).into_token_stream();
if !matches!(unexpanded.vis, Visibility::Public(_)) {
unexpanded.vis = Visibility::Public(Pub {
span: unexpanded.vis.span(),
@@ -690,6 +691,7 @@ fn component_macro(
}
unexpanded.sig.ident =
unmodified_fn_name_from_fn_name(&unexpanded.sig.ident);
+
quote! {
#expanded
diff --git a/reactive_graph/src/effect/render_effect.rs b/reactive_graph/src/effect/render_effect.rs
index 9462b4fbc..53892828b 100644
--- a/reactive_graph/src/effect/render_effect.rs
+++ b/reactive_graph/src/effect/render_effect.rs
@@ -11,7 +11,9 @@ use futures::StreamExt;
use or_poisoned::OrPoisoned;
use std::{
fmt::Debug,
+ future::{Future, IntoFuture},
mem,
+ pin::Pin,
sync::{Arc, RwLock, Weak},
};
@@ -64,6 +66,18 @@ where
Self::new_with_value_erased(Box::new(fun), initial_value)
}
+ /// Creates a new render effect, which immediately runs `fun`.
+ pub async fn new_with_async_value(
+ fun: impl FnMut(Option) -> T + 'static,
+ value: impl IntoFuture + 'static,
+ ) -> Self {
+ Self::new_with_async_value_erased(
+ Box::new(fun),
+ Box::pin(value.into_future()),
+ )
+ .await
+ }
+
fn new_with_value_erased(
mut fun: Box) -> T + 'static>,
initial_value: Option,
@@ -127,6 +141,73 @@ where
RenderEffect { value, inner }
}
+ async fn new_with_async_value_erased(
+ mut fun: Box) -> T + 'static>,
+ initial_value: Pin>>,
+ ) -> Self {
+ // codegen optimisation:
+ fn prep() -> (Owner, Arc>, crate::channel::Receiver)
+ {
+ let (observer, rx) = channel();
+ let owner = Owner::new();
+ let inner = Arc::new(RwLock::new(EffectInner {
+ dirty: false,
+ observer,
+ sources: SourceSet::new(),
+ }));
+ (owner, inner, rx)
+ }
+
+ let (owner, inner, mut rx) = prep();
+
+ let value = Arc::new(RwLock::new(None::));
+
+ #[cfg(not(feature = "effects"))]
+ {
+ drop(initial_value);
+ let _ = owner;
+ let _ = &mut rx;
+ let _ = &mut fun;
+ }
+
+ #[cfg(feature = "effects")]
+ {
+ use crate::computed::ScopedFuture;
+
+ let subscriber = inner.to_any_subscriber();
+
+ let initial = subscriber
+ .with_observer(|| ScopedFuture::new(initial_value))
+ .await;
+ *value.write().or_poisoned() = Some(initial);
+
+ any_spawner::Executor::spawn_local({
+ let value = Arc::clone(&value);
+
+ async move {
+ while rx.next().await.is_some() {
+ if !owner.paused()
+ && subscriber.with_observer(|| {
+ subscriber.update_if_necessary()
+ })
+ {
+ subscriber.clear_sources(&subscriber);
+
+ let old_value =
+ mem::take(&mut *value.write().or_poisoned());
+ let new_value = owner.with_cleanup(|| {
+ subscriber.with_observer(|| fun(old_value))
+ });
+ *value.write().or_poisoned() = Some(new_value);
+ }
+ }
+ }
+ });
+ }
+
+ RenderEffect { value, inner }
+ }
+
/// Mutably accesses the current value.
pub fn with_value_mut(
&self,
diff --git a/reactive_graph/tests/cleanup.rs b/reactive_graph/tests/cleanup.rs
index d1c2bb818..727da695b 100644
--- a/reactive_graph/tests/cleanup.rs
+++ b/reactive_graph/tests/cleanup.rs
@@ -44,7 +44,7 @@ fn cleanup_on_dispose() {
drop(on_drop)
});
});
- println!("Memo 1: {:?}", memo);
+ println!("Memo 1: {memo:?}");
memo.get_untracked(); // First cleanup registered.
memo.dispose(); // Cleanup not run here.
@@ -55,7 +55,7 @@ fn cleanup_on_dispose() {
// New cleanup registered. It'll panic here.
on_cleanup(move || println!("Test passed."));
});
- println!("Memo 2: {:?}", memo);
+ println!("Memo 2: {memo:?}");
println!("^ Note how the memos have the same key (different versions).");
memo.get_untracked(); // First cleanup registered.
diff --git a/router/src/flat_router.rs b/router/src/flat_router.rs
index 168e5885f..8e426a34d 100644
--- a/router/src/flat_router.rs
+++ b/router/src/flat_router.rs
@@ -141,15 +141,12 @@ where
}
let mut view = Box::pin(owner.with(|| {
- ScopedFuture::new({
- let url = url.clone();
- let matched = matched.clone();
- async move {
- provide_context(params_memo);
- provide_context(url);
- provide_context(Matched(ArcMemo::from(matched)));
- OwnedView::new(view.choose().await)
- }
+ provide_context(params_memo);
+ provide_context(url.clone());
+ provide_context(Matched(ArcMemo::from(matched.clone())));
+
+ ScopedFuture::new(async move {
+ OwnedView::new(view.choose().await)
})
}));
@@ -292,14 +289,13 @@ where
.map(|nav| nav.is_back().get_untracked())
.unwrap_or(false);
Executor::spawn_local(owner.with(|| {
+ provide_context(url);
+ provide_context(params_memo);
+ provide_context(Matched(ArcMemo::from(new_matched)));
+
ScopedFuture::new({
let state = Rc::clone(state);
async move {
- provide_context(url);
- provide_context(params_memo);
- provide_context(Matched(ArcMemo::from(
- new_matched,
- )));
let view = OwnedView::new(
if let Some(set_is_routing) = set_is_routing {
set_is_routing.set(true);
@@ -472,6 +468,14 @@ impl RenderHtml for MatchedRoute {
self.1.hydrate::(cursor, position)
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ self.1.hydrate_async(cursor, position).await
+ }
+
fn into_owned(self) -> Self::Owned {
self
}
@@ -513,12 +517,11 @@ where
let (view, _) = new_match.into_view_and_child();
let view = owner
.with(|| {
- ScopedFuture::new(async move {
- provide_context(url);
- provide_context(params_memo);
- provide_context(Matched(ArcMemo::from(matched)));
- view.choose().await
- })
+ provide_context(url);
+ provide_context(params_memo);
+ provide_context(Matched(ArcMemo::from(matched)));
+
+ ScopedFuture::new(async move { view.choose().await })
})
.now_or_never()
.expect("async route used in SSR");
@@ -632,17 +635,12 @@ where
)
}
+ #[track_caller]
fn hydrate(
self,
cursor: &Cursor,
position: &PositionState,
) -> Self::State {
- // this can be mostly the same as the build() implementation, but with hydrate()
- //
- // however, the big TODO is that we need to support lazy hydration in the case that the
- // route is lazy-loaded on the client -- in this case, we actually can't initially hydrate
- // at all, but need to skip, because the HTML will contain the route even though the
- // client-side route component code is not yet loaded
let FlatRoutesView {
current_url,
routes,
@@ -701,15 +699,12 @@ where
}
let mut view = Box::pin(owner.with(|| {
- ScopedFuture::new({
- let url = url.clone();
- let matched = matched.clone();
- async move {
- provide_context(params_memo);
- provide_context(url);
- provide_context(Matched(ArcMemo::from(matched)));
- OwnedView::new(view.choose().await)
- }
+ provide_context(params_memo);
+ provide_context(url.clone());
+ provide_context(Matched(ArcMemo::from(matched.clone())));
+
+ ScopedFuture::new(async move {
+ OwnedView::new(view.choose().await)
})
}));
@@ -726,14 +721,104 @@ where
matched,
})),
None => {
- // see comment at the top of this function
- todo!()
+ panic!(
+ "lazy routes should not be used with \
+ hydrate_body(); use hydrate_lazy() instead"
+ );
}
}
}
}
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let FlatRoutesView {
+ current_url,
+ routes,
+ fallback,
+ outer_owner,
+ ..
+ } = self;
+ let current_url = current_url.read_untracked();
+
+ // we always need to match the new route
+ let new_match = routes.match_route(current_url.path());
+ let id = new_match.as_ref().map(|n| n.as_id());
+ let matched = ArcRwSignal::new(
+ new_match
+ .as_ref()
+ .map(|n| n.as_matched().to_owned())
+ .unwrap_or_default(),
+ );
+
+ // create default starting points for owner, url, path, and params
+ // these will be held in state so that future navigations can update or replace them
+ let owner = outer_owner.child();
+ let url = ArcRwSignal::new(current_url.to_owned());
+ let path = current_url.path().to_string();
+ let params = ArcRwSignal::new(
+ new_match
+ .as_ref()
+ .map(|n| n.to_params().into_iter().collect())
+ .unwrap_or_default(),
+ );
+ let params_memo = ArcMemo::from(params.clone());
+
+ // release URL lock
+ drop(current_url);
+
+ match new_match {
+ None => Rc::new(RefCell::new(FlatRoutesViewState {
+ view: fallback()
+ .into_any()
+ .hydrate_async(cursor, position)
+ .await,
+ id,
+ owner,
+ params,
+ path,
+ url,
+ matched,
+ })),
+ Some(new_match) => {
+ let (view, child) = new_match.into_view_and_child();
+
+ #[cfg(debug_assertions)]
+ if child.is_some() {
+ panic!(
+ " should not be used with nested routes."
+ );
+ }
+
+ let view = Box::pin(owner.with(|| {
+ provide_context(params_memo);
+ provide_context(url.clone());
+ provide_context(Matched(ArcMemo::from(matched.clone())));
+
+ ScopedFuture::new(async move {
+ OwnedView::new(view.choose().await)
+ })
+ }));
+
+ let view = view.await;
+
+ Rc::new(RefCell::new(FlatRoutesViewState {
+ view: view.into_any().hydrate_async(cursor, position).await,
+ id,
+ owner,
+ params,
+ path,
+ url,
+ matched,
+ }))
+ }
+ }
+ }
+
fn into_owned(self) -> Self::Owned {
self
}
diff --git a/router/src/lib.rs b/router/src/lib.rs
index 5160d15aa..5aa2b901a 100644
--- a/router/src/lib.rs
+++ b/router/src/lib.rs
@@ -148,7 +148,7 @@ pub mod static_routes;
pub use generate_route_list::*;
#[doc(inline)]
-pub use leptos_router_macro::path;
+pub use leptos_router_macro::{lazy_route, path};
pub use matching::*;
pub use method::*;
pub use navigate::*;
diff --git a/router/src/matching/any_choose_view.rs b/router/src/matching/any_choose_view.rs
index 09c001263..a9bf9aa9c 100644
--- a/router/src/matching/any_choose_view.rs
+++ b/router/src/matching/any_choose_view.rs
@@ -7,6 +7,7 @@ use tachys::{erased::Erased, view::any_view::AnyView};
pub struct AnyChooseView {
value: Erased,
clone: fn(&Erased) -> AnyChooseView,
+ #[allow(clippy::type_complexity)]
choose: fn(Erased) -> Pin>>,
preload: for<'a> fn(&'a Erased) -> Pin + 'a>>,
}
diff --git a/router/src/matching/choose_view.rs b/router/src/matching/choose_view.rs
index fe9fc485e..c4bdda84f 100644
--- a/router/src/matching/choose_view.rs
+++ b/router/src/matching/choose_view.rs
@@ -1,4 +1,5 @@
use either_of::*;
+use leptos::prelude::{ArcStoredValue, WriteValue};
use std::{future::Future, marker::PhantomData};
use tachys::view::any_view::{AnyView, IntoAny};
@@ -25,31 +26,41 @@ where
impl ChooseView for Lazy
where
- T: LazyRoute,
+ T: Send + Sync + LazyRoute,
{
async fn choose(self) -> AnyView {
- T::data().view().await.into_any()
+ let data = self.data.write_value().take().unwrap_or_else(T::data);
+ T::view(data).await
}
async fn preload(&self) {
- T::data().view().await;
+ *self.data.write_value() = Some(T::data());
+ T::preload().await;
}
}
pub trait LazyRoute: Send + 'static {
fn data() -> Self;
- fn view(self) -> impl Future;
+ fn view(this: Self) -> impl Future;
+
+ fn preload() -> impl Future {
+ async {}
+ }
}
#[derive(Debug)]
pub struct Lazy {
ty: PhantomData,
+ data: ArcStoredValue>,
}
impl Clone for Lazy {
fn clone(&self) -> Self {
- Self { ty: self.ty }
+ Self {
+ ty: self.ty,
+ data: self.data.clone(),
+ }
}
}
@@ -63,6 +74,7 @@ impl Default for Lazy {
fn default() -> Self {
Self {
ty: Default::default(),
+ data: ArcStoredValue::new(None),
}
}
}
@@ -101,9 +113,11 @@ macro_rules! tuples {
where
$($ty: ChooseView,)*
{
- async fn choose(self ) -> AnyView {
+ async fn choose(self) -> AnyView {
match self {
- $($either::$ty(f) => f.choose().await.into_any(),)*
+ $(
+ $either::$ty(f) => f.choose().await.into_any(),
+ )*
}
}
diff --git a/router/src/nested_router.rs b/router/src/nested_router.rs
index 0f4617c8b..fbf8be98a 100644
--- a/router/src/nested_router.rs
+++ b/router/src/nested_router.rs
@@ -447,10 +447,10 @@ where
);
drop(url);
- // TODO support for lazy hydration
- join_all(mem::take(&mut loaders))
- .now_or_never()
- .expect("async routes not supported in SSR");
+ join_all(mem::take(&mut loaders)).now_or_never().expect(
+ "lazy routes not supported with hydrate_body(); use \
+ hydrate_lazy() instead",
+ );
EitherOf3::C(top_level_outlet(&outlets, &outer_owner))
}
}
@@ -466,6 +466,57 @@ where
}
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let NestedRoutesView {
+ routes,
+ outer_owner,
+ current_url,
+ fallback,
+ base,
+ ..
+ } = self;
+
+ let mut loaders = Vec::new();
+ let mut outlets = Vec::new();
+ let url = current_url.read_untracked();
+ let path = url.path().to_string();
+
+ // match the route
+ let new_match = routes.match_route(url.path());
+
+ // start with an empty view because we'll be loading routes async
+ let view = Rc::new(RefCell::new(
+ match new_match {
+ None => EitherOf3::B(fallback()),
+ Some(route) => {
+ route.build_nested_route(
+ &url,
+ base,
+ &mut loaders,
+ &mut outlets,
+ );
+ drop(url);
+
+ join_all(mem::take(&mut loaders)).await;
+ EitherOf3::C(top_level_outlet(&outlets, &outer_owner))
+ }
+ }
+ .hydrate::(cursor, position),
+ ));
+
+ NestedRouteViewState {
+ path,
+ current_url,
+ outlets,
+ view,
+ outer_owner,
+ }
+ }
+
fn into_owned(self) -> Self::Owned {
self
}
@@ -638,6 +689,9 @@ where
let url = url.clone();
let matched = matched.clone();
async move {
+ provide_context(params.clone());
+ provide_context(url.clone());
+ provide_context(matched.clone());
view.preload().await;
let child = outlet.child.clone();
*view_fn.lock().or_poisoned() =
@@ -651,7 +705,7 @@ where
let matched = matched.clone();
owner_where_used.with({
let matched = matched.clone();
- move || {
+ || {
let child = child.clone();
Suspend::new(Box::pin(async move {
provide_context(child.clone());
@@ -781,8 +835,6 @@ where
})
};
- // assign a new owner, so that contexts and signals owned by the previous route
- // in this outlet can be dropped
let (full_tx, full_rx) = oneshot::channel();
let full_tx = Mutex::new(Some(full_tx));
full_loaders.push(full_rx);
@@ -799,8 +851,12 @@ where
let route_owner = Arc::clone(¤t.owner);
let child = outlet.child.clone();
async move {
- view.preload().await;
let child = child.clone();
+ if set_is_routing {
+ AsyncTransition::run(|| view.preload()).await;
+ } else {
+ view.preload().await;
+ }
*view_fn.lock().or_poisoned() =
Box::new(move |owner_where_used| {
let prev_owner = route_owner
diff --git a/router_macro/src/lib.rs b/router_macro/src/lib.rs
index c46fe4fb7..34132f63e 100644
--- a/router_macro/src/lib.rs
+++ b/router_macro/src/lib.rs
@@ -6,10 +6,10 @@
use proc_macro::{TokenStream, TokenTree};
use proc_macro2::Span;
-use proc_macro_error2::{abort, proc_macro_error};
+use proc_macro_error2::{abort, proc_macro_error, set_dummy};
use quote::{quote, ToTokens};
use syn::{
- spanned::Spanned, Block, Ident, ImplItem, ItemImpl, Path, Type, TypePath,
+ spanned::Spanned, FnArg, Ident, ImplItem, ItemImpl, Path, Type, TypePath,
};
const RFC3986_UNRESERVED: [char; 4] = ['-', '.', '_', '~'];
@@ -213,7 +213,9 @@ fn lazy_route_impl(
_args: proc_macro::TokenStream,
s: TokenStream,
) -> TokenStream {
- let mut im = syn::parse::(s).unwrap_or_else(|e| {
+ set_dummy(s.clone().into());
+
+ let mut im = syn::parse::(s.clone()).unwrap_or_else(|e| {
abort!(e.span(), "`lazy_route` can only be used on an `impl` block")
});
if im.trait_.is_none() {
@@ -232,7 +234,30 @@ fn lazy_route_impl(
}) => segments.last().unwrap().ident.to_string(),
_ => abort!(self_ty.span(), "only path types are supported"),
};
- let lazy_view_ident = Ident::new(&ty_name_to_snake, im.self_ty.span());
+ let lazy_view_ident =
+ Ident::new(&format!("__{ty_name_to_snake}_View"), im.self_ty.span());
+ let preload_lazy_view_ident = Ident::new(
+ &format!("__preload_{lazy_view_ident}"),
+ lazy_view_ident.span(),
+ );
+
+ im.items.push(
+ syn::parse::(
+ quote! {
+ async fn preload() {
+ // TODO for 0.9 this is not precise
+ // we don't split routes for wasm32 ssr
+ // but we don't require a `hydrate`/`csr` feature on leptos_router
+ #[cfg(target_arch = "wasm32")]
+ #preload_lazy_view_ident().await;
+ }
+ }
+ .into(),
+ )
+ .unwrap_or_else(|e| {
+ abort!(e.span(), "could not parse preload item impl")
+ }),
+ );
let item = im.items.iter_mut().find_map(|item| match item {
ImplItem::Fn(inner) => {
@@ -244,28 +269,47 @@ fn lazy_route_impl(
}
_ => None,
});
- match item {
- None => abort!(im.span(), "must contain a fn called `view`"),
- Some(fun) => {
- let body = fun.block.clone();
- let new_block = quote! {{
- #[cfg_attr(feature = "split", wasm_split::wasm_split(#lazy_view_ident))]
- async fn view(this: #self_ty) -> ::leptos::prelude::AnyView {
- #body
- }
- view(self).await
- }};
- let block =
- syn::parse::(new_block.into()).unwrap_or_else(|e| {
- abort!(
- e.span(),
- "`lazy_route` can only be used on an `impl` block"
- )
- });
- fun.block = block;
+ match item {
+ None => s,
+ Some(fun) => {
+ if let Some(a) = fun.sig.asyncness {
+ abort!(a.span(), "`view` method should not be async")
+ }
+ fun.sig.asyncness = Some(Default::default());
+
+ let first_arg = fun.sig.inputs.first().unwrap_or_else(|| {
+ abort!(fun.sig.span(), "must have an argument")
+ });
+ let FnArg::Typed(first_arg) = first_arg else {
+ abort!(
+ first_arg.span(),
+ "this must be a typed argument like `this: Self`"
+ )
+ };
+ let first_arg_pat = &*first_arg.pat;
+ let body = std::mem::replace(
+ &mut fun.block,
+ syn::parse(
+ quote! {
+ {
+ #lazy_view_ident(#first_arg_pat).await
+ }
+ }
+ .into(),
+ )
+ .unwrap(),
+ );
+
+ quote! {
+ #[allow(non_snake_case)]
+ #[::leptos::lazy]
+ fn #lazy_view_ident(#first_arg_pat: #self_ty) -> ::leptos::prelude::AnyView {
+ #body
+ }
+
+ #im
+ }.into()
}
}
-
- quote! { #im }.into()
}
diff --git a/tachys/src/html/element/mod.rs b/tachys/src/html/element/mod.rs
index 3b5858bdb..98191e0fe 100644
--- a/tachys/src/html/element/mod.rs
+++ b/tachys/src/html/element/mod.rs
@@ -564,6 +564,77 @@ where
}
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ // codegen optimisation:
+ fn inner_1(
+ cursor: &Cursor,
+ position: &PositionState,
+ tag_name: &str,
+ #[cfg(any(debug_assertions, leptos_debuginfo))]
+ defined_at: &'static std::panic::Location<'static>,
+ ) -> crate::renderer::types::Element {
+ #[cfg(any(debug_assertions, leptos_debuginfo))]
+ {
+ set_currently_hydrating(Some(defined_at));
+ }
+
+ let curr_position = position.get();
+ if curr_position == Position::FirstChild {
+ cursor.child();
+ } else if curr_position != Position::Current {
+ cursor.sibling();
+ }
+ crate::renderer::types::Element::cast_from(cursor.current())
+ .unwrap_or_else(|| {
+ failed_to_cast_element(tag_name, cursor.current())
+ })
+ }
+ let el = inner_1(
+ cursor,
+ position,
+ E::TAG,
+ #[cfg(any(debug_assertions, leptos_debuginfo))]
+ self.defined_at,
+ );
+
+ let attrs = self.attributes.hydrate::(&el);
+
+ // hydrate children
+ let children = if !Ch::EXISTS || !E::ESCAPE_CHILDREN {
+ None
+ } else {
+ position.set(Position::FirstChild);
+ Some(self.children.hydrate_async(cursor, position).await)
+ };
+
+ // codegen optimisation:
+ fn inner_2(
+ cursor: &Cursor,
+ position: &PositionState,
+ el: &crate::renderer::types::Element,
+ ) {
+ // go to next sibling
+ cursor.set(
+ >::as_ref(el)
+ .clone(),
+ );
+ position.set(Position::NextChild);
+ }
+ inner_2(cursor, position, &el);
+
+ ElementState {
+ el,
+ attrs,
+ children,
+ }
+ }
+
fn into_owned(self) -> Self::Owned {
HtmlElement {
#[cfg(any(debug_assertions, leptos_debuginfo))]
diff --git a/tachys/src/reactive_graph/mod.rs b/tachys/src/reactive_graph/mod.rs
index 1069370f0..7b06c1d75 100644
--- a/tachys/src/reactive_graph/mod.rs
+++ b/tachys/src/reactive_graph/mod.rs
@@ -231,6 +231,58 @@ where
.into()
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ /// codegen optimisation:
+ fn prep(
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> (
+ Cursor,
+ PositionState,
+ Option>,
+ ) {
+ let cursor = cursor.clone();
+ let position = position.clone();
+ let hook = throw_error::get_error_hook();
+ (cursor, position, hook)
+ }
+ let (cursor, position, hook) = prep(cursor, position);
+
+ let mut fun = self.into_shared();
+
+ RenderEffect::new_with_async_value(
+ {
+ let mut fun = fun.clone();
+ move |prev| {
+ /// codegen optimisation:
+ fn get_guard(
+ hook: &Option>,
+ ) -> Option
+ {
+ hook.as_ref()
+ .map(|h| throw_error::set_error_hook(Arc::clone(h)))
+ }
+ let _guard = get_guard(&hook);
+
+ let value = fun.invoke();
+ if let Some(mut state) = prev {
+ value.rebuild(&mut state);
+ state
+ } else {
+ unreachable!()
+ }
+ }
+ },
+ async move { fun.invoke().hydrate_async(&cursor, &position).await },
+ )
+ .await
+ .into()
+ }
+
fn into_owned(self) -> Self::Owned {
self
}
diff --git a/tachys/src/view/any_view.rs b/tachys/src/view/any_view.rs
index 42168ad95..8cfaa34c6 100644
--- a/tachys/src/view/any_view.rs
+++ b/tachys/src/view/any_view.rs
@@ -17,7 +17,7 @@ use crate::{
};
use futures::future::{join, join_all};
use std::{any::TypeId, fmt::Debug};
-#[cfg(feature = "ssr")]
+#[cfg(any(feature = "ssr", feature = "hydrate"))]
use std::{future::Future, pin::Pin};
/// A type-erased view. This can be used if control flow requires that multiple different types of
@@ -70,6 +70,13 @@ pub struct AnyView {
#[cfg(feature = "hydrate")]
#[allow(clippy::type_complexity)]
hydrate_from_server: fn(Erased, &Cursor, &PositionState) -> AnyViewState,
+ #[cfg(feature = "hydrate")]
+ #[allow(clippy::type_complexity)]
+ hydrate_async: fn(
+ Erased,
+ &Cursor,
+ &PositionState,
+ ) -> Pin>>,
}
impl Debug for AnyView {
@@ -299,6 +306,35 @@ where
}
}
+ #[cfg(feature = "hydrate")]
+ fn hydrate_async(
+ value: Erased,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Pin>> {
+ let cursor = cursor.clone();
+ let position = position.clone();
+ Box::pin(async move {
+ let state = ErasedLocal::new(
+ value
+ .into_inner::()
+ .hydrate_async(&cursor, &position)
+ .await,
+ );
+ let placeholder =
+ (!T::EXISTS).then(|| cursor.next_placeholder(&position));
+ AnyViewState {
+ type_id: TypeId::of::(),
+ state,
+ mount: mount_any::,
+ unmount: unmount_any::,
+ insert_before_this: insert_before_this::,
+ elements: elements::,
+ placeholder,
+ }
+ })
+ }
+
fn rebuild(
value: Erased,
state: &mut AnyViewState,
@@ -326,6 +362,8 @@ where
to_html_async_ooo: to_html_async_ooo::,
#[cfg(feature = "hydrate")]
hydrate_from_server: hydrate_from_server::,
+ #[cfg(feature = "hydrate")]
+ hydrate_async: hydrate_async::,
value: Erased::new(value),
}
}
@@ -557,6 +595,34 @@ impl RenderHtml for AnyView {
}
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ #[cfg(feature = "hydrate")]
+ {
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ let state =
+ (self.hydrate_async)(self.value, cursor, position).await;
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ state
+ }
+ #[cfg(not(feature = "hydrate"))]
+ {
+ _ = cursor;
+ _ = position;
+ panic!(
+ "You are trying to hydrate AnyView without the `hydrate` \
+ feature enabled."
+ );
+ }
+ }
+
fn html_len(&self) -> usize {
#[cfg(feature = "ssr")]
{
@@ -713,6 +779,22 @@ impl RenderHtml for AnyViewWithAttrs {
AnyViewWithAttrsState { view, attrs }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let view = self.view.hydrate_async(cursor, position).await;
+ let elements = view.elements();
+ let mut attrs = Vec::with_capacity(elements.len() * self.attrs.len());
+ for attr in self.attrs {
+ for el in &elements {
+ attrs.push(attr.clone().hydrate::(el));
+ }
+ }
+ AnyViewWithAttrsState { view, attrs }
+ }
+
fn html_len(&self) -> usize {
self.view.html_len()
+ self.attrs.iter().map(|attr| attr.html_len()).sum::()
diff --git a/tachys/src/view/either.rs b/tachys/src/view/either.rs
index 228fe442e..2b5d5d5b2 100644
--- a/tachys/src/view/either.rs
+++ b/tachys/src/view/either.rs
@@ -418,6 +418,28 @@ where
state
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ let state = match self {
+ Either::Left(left) => {
+ Either::Left(left.hydrate_async(cursor, position).await)
+ }
+ Either::Right(right) => {
+ Either::Right(right.hydrate_async(cursor, position).await)
+ }
+ };
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ state
+ }
+
fn into_owned(self) -> Self::Owned {
match self {
Either::Left(left) => Either::Left(left.into_owned()),
@@ -649,6 +671,34 @@ where
EitherKeepAliveState { showing_b, a, b }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let showing_b = self.show_b;
+ let a = if let Some(a) = self.a {
+ Some(if showing_b {
+ a.build()
+ } else {
+ a.hydrate_async(cursor, position).await
+ })
+ } else {
+ None
+ };
+ let b = if let Some(b) = self.b {
+ Some(if showing_b {
+ b.hydrate_async(cursor, position).await
+ } else {
+ b.build()
+ })
+ } else {
+ None
+ };
+
+ EitherKeepAliveState { showing_b, a, b }
+ }
+
fn into_owned(self) -> Self::Owned {
EitherKeepAlive {
a: self.a.map(|a| a.into_owned()),
@@ -928,6 +978,26 @@ macro_rules! tuples {
Self::State { state }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ let state = match self {
+ $([]::$ty(this) => {
+ []::$ty(this.hydrate_async(cursor, position).await)
+ })*
+ };
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+
+ Self::State { state }
+ }
+
fn into_owned(self) -> Self::Owned {
match self {
$([]::$ty(this) => {
diff --git a/tachys/src/view/error_boundary.rs b/tachys/src/view/error_boundary.rs
index 7ef8c361f..32b90ecb5 100644
--- a/tachys/src/view/error_boundary.rs
+++ b/tachys/src/view/error_boundary.rs
@@ -233,6 +233,26 @@ where
ResultState { state, error, hook }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let hook = throw_error::get_error_hook();
+ let (state, error) = match self {
+ Ok(view) => (
+ Either::Left(view.hydrate_async(cursor, position).await),
+ None,
+ ),
+ Err(e) => {
+ let state =
+ RenderHtml::hydrate_async((), cursor, position).await;
+ (Either::Right(state), Some(throw_error::throw(e.into())))
+ }
+ };
+ ResultState { state, error, hook }
+ }
+
fn into_owned(self) -> Self::Owned {
match self {
Ok(view) => Ok(view.into_owned()),
diff --git a/tachys/src/view/iterators.rs b/tachys/src/view/iterators.rs
index bd1ce78fb..721f274f7 100644
--- a/tachys/src/view/iterators.rs
+++ b/tachys/src/view/iterators.rs
@@ -141,6 +141,19 @@ where
.hydrate::(cursor, position)
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ match self {
+ Some(value) => Either::Left(value),
+ None => Either::Right(()),
+ }
+ .hydrate_async(cursor, position)
+ .await
+ }
+
fn into_owned(self) -> Self::Owned {
self.map(RenderHtml::into_owned)
}
@@ -385,6 +398,22 @@ where
VecState { states, marker }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let mut states = Vec::with_capacity(self.len());
+ for child in self {
+ states.push(child.hydrate_async(cursor, position).await);
+ }
+
+ let marker = cursor.next_placeholder(position);
+ position.set(Position::NextChild);
+
+ VecState { states, marker }
+ }
+
fn into_owned(self) -> Self::Owned {
self.into_iter()
.map(RenderHtml::into_owned)
@@ -648,6 +677,22 @@ where
Self::State { states, marker }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let mut states = Vec::with_capacity(self.0.len());
+ for child in self.0 {
+ states.push(child.hydrate_async(cursor, position).await);
+ }
+
+ let marker = cursor.next_placeholder(position);
+ position.set(Position::NextChild);
+
+ Self::State { states, marker }
+ }
+
fn into_owned(self) -> Self::Owned {
self.0
.into_iter()
@@ -818,6 +863,21 @@ where
ArrayState { states }
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ let mut states = Vec::with_capacity(self.len());
+ for child in self {
+ states.push(child.hydrate_async(cursor, position).await);
+ }
+ let Ok(states) = <[::State; N]>::try_from(states) else {
+ unreachable!()
+ };
+ ArrayState { states }
+ }
+
fn into_owned(self) -> Self::Owned {
self.into_iter()
.map(RenderHtml::into_owned)
diff --git a/tachys/src/view/keyed.rs b/tachys/src/view/keyed.rs
index 15ee99abc..2278bb281 100644
--- a/tachys/src/view/keyed.rs
+++ b/tachys/src/view/keyed.rs
@@ -370,6 +370,59 @@ where
}
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+
+ // get parent and position
+ let current = cursor.current();
+ let parent = if position.get() == Position::FirstChild {
+ current
+ } else {
+ Rndr::get_parent(¤t)
+ .expect("first child of keyed list has no parent")
+ };
+ let parent = crate::renderer::types::Element::cast_from(parent)
+ .expect("parent of keyed list should be an element");
+
+ // build list
+ let items = self.items.into_iter();
+ let (capacity, _) = items.size_hint();
+ let mut hashed_items =
+ FxIndexSet::with_capacity_and_hasher(capacity, Default::default());
+ let mut rendered_items = Vec::new();
+ for (index, item) in items.enumerate() {
+ hashed_items.insert((self.key_fn)(&item));
+ let (set_index, view) = (self.view_fn)(index, item);
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ let item = view.hydrate_async(cursor, position).await;
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+ rendered_items.push(Some((set_index, item)));
+ }
+ let marker = cursor.next_placeholder(position);
+ position.set(Position::NextChild);
+
+ if cfg!(feature = "mark_branches") {
+ cursor.advance_to_placeholder(position);
+ }
+
+ KeyedState {
+ parent: Some(parent),
+ marker,
+ hashed_items,
+ rendered_items,
+ }
+ }
+
fn into_owned(self) -> Self::Owned {
self
}
diff --git a/tachys/src/view/mod.rs b/tachys/src/view/mod.rs
index 479810514..ad1e0f43d 100644
--- a/tachys/src/view/mod.rs
+++ b/tachys/src/view/mod.rs
@@ -278,6 +278,19 @@ where
position: &PositionState,
) -> Self::State;
+ /// Asynchronously makes a set of DOM nodes rendered from HTML interactive.
+ ///
+ /// Async hydration is useful for types that may need to wait before being hydrated:
+ /// for example, lazily-loaded routes need async hydration, because the client code
+ /// may be loading asynchronously, while the server HTML was already rendered.
+ fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> impl Future {
+ async { self.hydrate::(cursor, position) }
+ }
+
/// Hydrates using [`RenderHtml::hydrate`], beginning at the given element.
fn hydrate_from(
self,
diff --git a/tachys/src/view/tuples.rs b/tachys/src/view/tuples.rs
index 3b8535f67..b18b4f39e 100644
--- a/tachys/src/view/tuples.rs
+++ b/tachys/src/view/tuples.rs
@@ -184,6 +184,14 @@ where
self.0.hydrate::(cursor, position)
}
+ async fn hydrate_async(
+ self,
+ cursor: &Cursor,
+ position: &PositionState,
+ ) -> Self::State {
+ self.0.hydrate_async(cursor, position).await
+ }
+
async fn resolve(self) -> Self::AsyncOutput {
(self.0.resolve().await,)
}
@@ -316,6 +324,15 @@ macro_rules! impl_view_for_tuples {
)
}
+ async fn hydrate_async(self, cursor: &Cursor, position: &PositionState) -> Self::State {
+ #[allow(non_snake_case)]
+ let ($first, $($ty,)* ) = self;
+ (
+ $first.hydrate_async(cursor, position).await,
+ $($ty.hydrate_async(cursor, position).await),*
+ )
+ }
+
async fn resolve(self) -> Self::AsyncOutput {
#[allow(non_snake_case)]
let ($first, $($ty,)*) = self;
diff --git a/wasm_split/Cargo.toml b/wasm_split/Cargo.toml
new file mode 100644
index 000000000..ad878d9cc
--- /dev/null
+++ b/wasm_split/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "wasm_split_helpers"
+version = "0.1.0"
+authors = ["Greg Johnston"]
+license = "MIT"
+readme = "README.md"
+repository = "https://github.com/leptos-rs/leptos"
+description = "Tools to support code-splitting and lazy loading for WebAssembly (WASM) binaries."
+rust-version.workspace = true
+edition.workspace = true
+
+[dependencies]
+async-once-cell = { default-features = true, workspace = true, features = [
+ "std",
+] }
+wasm_split_macros.workspace = true
diff --git a/wasm_split/Makefile.toml b/wasm_split/Makefile.toml
new file mode 100644
index 000000000..3d822c68d
--- /dev/null
+++ b/wasm_split/Makefile.toml
@@ -0,0 +1 @@
+extend = { path = "../cargo-make/main.toml" }
diff --git a/wasm_split/README.md b/wasm_split/README.md
new file mode 100644
index 000000000..6f065cf85
--- /dev/null
+++ b/wasm_split/README.md
@@ -0,0 +1,9 @@
+# `wasm_split_helpers`
+
+This crate provides functions that are used by the `wasm_split_macros` crate, which allows you to indicate that certain functions are appropriate split points for lazy-loaded code.
+
+A build tool that supports this approach (like `cargo-leptos`) can then split a WebAssembly (WASM) binary into multiple chunks, which will be lazy-loaded when a split function is called.
+
+This crate was adapted from an original prototype, which you can find [here](https://github.com/jbms/wasm-split-prototype), with an in-depth description of the approach [here](https://github.com/rustwasm/wasm-bindgen/issues/3939).
+
+This functionality is provided in Leptos by the `#[lazy]` and `#[lazy_route]` macros.
diff --git a/wasm_split/src/lib.rs b/wasm_split/src/lib.rs
new file mode 100644
index 000000000..beeff3047
--- /dev/null
+++ b/wasm_split/src/lib.rs
@@ -0,0 +1,107 @@
+use std::{
+ cell::Cell,
+ ffi::c_void,
+ future::Future,
+ pin::Pin,
+ rc::Rc,
+ task::{Context, Poll, Waker},
+};
+
+pub type LoadCallbackFn = unsafe extern "C" fn(*const c_void, bool) -> ();
+pub type LoadFn = unsafe extern "C" fn(LoadCallbackFn, *const c_void) -> ();
+
+type Lazy = async_once_cell::Lazy, SplitLoaderFuture>;
+
+pub use wasm_split_macros::wasm_split;
+
+pub struct LazySplitLoader {
+ lazy: Pin>,
+}
+
+impl LazySplitLoader {
+ pub fn new(load: LoadFn) -> Self {
+ Self {
+ lazy: Rc::pin(Lazy::new(SplitLoaderFuture::new(SplitLoader::new(
+ load,
+ )))),
+ }
+ }
+}
+
+pub async fn ensure_loaded(
+ loader: &'static std::thread::LocalKey,
+) -> Option<()> {
+ *loader.with(|inner| inner.lazy.clone()).as_ref().await
+}
+
+#[derive(Clone, Copy, Debug)]
+enum SplitLoaderState {
+ Deferred(LoadFn),
+ Pending,
+ Completed(Option<()>),
+}
+
+struct SplitLoader {
+ state: Cell,
+ waker: Cell>,
+}
+
+impl SplitLoader {
+ fn new(load: LoadFn) -> Rc {
+ Rc::new(SplitLoader {
+ state: Cell::new(SplitLoaderState::Deferred(load)),
+ waker: Cell::new(None),
+ })
+ }
+
+ fn complete(&self, value: bool) {
+ self.state.set(SplitLoaderState::Completed(if value {
+ Some(())
+ } else {
+ None
+ }));
+ if let Some(waker) = self.waker.take() {
+ waker.wake();
+ }
+ }
+}
+
+struct SplitLoaderFuture {
+ loader: Rc,
+}
+
+impl SplitLoaderFuture {
+ fn new(loader: Rc) -> Self {
+ SplitLoaderFuture { loader }
+ }
+}
+
+impl Future for SplitLoaderFuture {
+ type Output = Option<()>;
+
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> {
+ match self.loader.state.get() {
+ SplitLoaderState::Deferred(load) => {
+ self.loader.state.set(SplitLoaderState::Pending);
+ self.loader.waker.set(Some(cx.waker().clone()));
+ unsafe {
+ load(
+ load_callback,
+ Rc::::into_raw(self.loader.clone())
+ as *const c_void,
+ )
+ };
+ Poll::Pending
+ }
+ SplitLoaderState::Pending => {
+ self.loader.waker.set(Some(cx.waker().clone()));
+ Poll::Pending
+ }
+ SplitLoaderState::Completed(value) => Poll::Ready(value),
+ }
+ }
+}
+
+unsafe extern "C" fn load_callback(loader: *const c_void, success: bool) {
+ unsafe { Rc::from_raw(loader as *const SplitLoader) }.complete(success);
+}
diff --git a/wasm_split_macros/Cargo.toml b/wasm_split_macros/Cargo.toml
new file mode 100644
index 000000000..b55dce474
--- /dev/null
+++ b/wasm_split_macros/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "wasm_split_macros"
+version = "0.1.0"
+authors = ["Greg Johnston"]
+license = "MIT"
+readme = "README.md"
+repository = "https://github.com/leptos-rs/leptos"
+description = "Tools to support code-splitting and lazy loading for WebAssembly (WASM) binaries."
+rust-version.workspace = true
+edition.workspace = true
+
+[dependencies]
+base16 = { workspace = true, default-features = true }
+digest = { workspace = true, default-features = true }
+quote = { workspace = true, default-features = true }
+sha2 = { workspace = true, default-features = true }
+syn = { workspace = true, default-features = true }
+wasm-bindgen = { workspace = true, default-features = true }
+
+[lib]
+proc-macro = true
diff --git a/wasm_split_macros/Makefile.toml b/wasm_split_macros/Makefile.toml
new file mode 100644
index 000000000..3d822c68d
--- /dev/null
+++ b/wasm_split_macros/Makefile.toml
@@ -0,0 +1 @@
+extend = { path = "../cargo-make/main.toml" }
diff --git a/wasm_split_macros/README.md b/wasm_split_macros/README.md
new file mode 100644
index 000000000..2ea64ef29
--- /dev/null
+++ b/wasm_split_macros/README.md
@@ -0,0 +1,9 @@
+# `wasm_split_macros`
+
+This crate provides macros that are used along with the `wasm_split_helpers` crate, which allows you to indicate that certain functions are appropriate split points for lazy-loaded code.
+
+A build tool that supports this approach (like `cargo-leptos`) can then split a WebAssembly (WASM) binary into multiple chunks, which will be lazy-loaded when a split function is called.
+
+This crate was adapted from an original prototype, which you can find [here](https://github.com/jbms/wasm-split-prototype), with an in-depth description of the approach [here](https://github.com/rustwasm/wasm-bindgen/issues/3939).
+
+This functionality is provided in Leptos by the `#[lazy]` and `#[lazy_route]` macros.
diff --git a/wasm_split_macros/src/lib.rs b/wasm_split_macros/src/lib.rs
new file mode 100644
index 000000000..f0ded38aa
--- /dev/null
+++ b/wasm_split_macros/src/lib.rs
@@ -0,0 +1,133 @@
+use digest::Digest;
+use proc_macro::TokenStream;
+use quote::{format_ident, quote};
+use syn::{parse_macro_input, Ident, ItemFn, ReturnType, Signature};
+
+#[proc_macro_attribute]
+pub fn wasm_split(args: TokenStream, input: TokenStream) -> TokenStream {
+ let module_ident = parse_macro_input!(args as Ident);
+ let item_fn = parse_macro_input!(input as ItemFn);
+
+ let name = &item_fn.sig.ident;
+
+ let preload_name =
+ Ident::new(&format!("__preload_{}", item_fn.sig.ident), name.span());
+
+ let unique_identifier = base16::encode_lower(
+ &sha2::Sha256::digest(format!("{name} {span:?}", span = name.span()))
+ [..16],
+ );
+
+ let load_module_ident = format_ident!("__wasm_split_load_{module_ident}");
+ let split_loader_ident =
+ format_ident!("__wasm_split_loader_{unique_identifier}");
+ let impl_import_ident = format_ident!(
+ "__wasm_split_00{module_ident}00_import_{unique_identifier}_{name}"
+ );
+ let impl_export_ident = format_ident!(
+ "__wasm_split_00{module_ident}00_export_{unique_identifier}_{name}"
+ );
+
+ let mut import_sig = Signature {
+ ident: impl_import_ident.clone(),
+ asyncness: None,
+ ..item_fn.sig.clone()
+ };
+ let mut export_sig = Signature {
+ ident: impl_export_ident.clone(),
+ asyncness: None,
+ ..item_fn.sig.clone()
+ };
+
+ let was_async = item_fn.sig.asyncness.is_some();
+ if was_async {
+ let ty = match &item_fn.sig.output {
+ ReturnType::Default => quote! { () },
+ ReturnType::Type(_, ty) => quote! { #ty },
+ };
+ let async_output = syn::parse::(
+ quote! {
+ -> std::pin::Pin>>
+ }
+ .into(),
+ )
+ .unwrap();
+ export_sig.output = async_output.clone();
+ import_sig.output = async_output;
+ }
+
+ let mut wrapper_sig = item_fn.sig;
+ wrapper_sig.asyncness = Some(Default::default());
+ let mut args = Vec::new();
+ for (i, param) in wrapper_sig.inputs.iter_mut().enumerate() {
+ match param {
+ syn::FnArg::Typed(pat_type) => {
+ let param_ident = format_ident!("__wasm_split_arg_{i}");
+ args.push(param_ident.clone());
+ pat_type.pat = Box::new(syn::Pat::Ident(syn::PatIdent {
+ attrs: vec![],
+ by_ref: None,
+ mutability: None,
+ ident: param_ident,
+ subpat: None,
+ }));
+ }
+ syn::FnArg::Receiver(_) => {
+ args.push(format_ident!("self"));
+ }
+ }
+ }
+
+ let attrs = item_fn.attrs;
+
+ let stmts = &item_fn.block.stmts;
+
+ let body = if was_async {
+ quote! {
+ Box::pin(async move {
+ #(#stmts)*
+ })
+ }
+ } else {
+ quote! { #(#stmts)* }
+ };
+
+ let await_result = was_async.then(|| quote! { .await });
+
+ quote! {
+ thread_local! {
+ static #split_loader_ident: ::leptos::wasm_split_helpers::LazySplitLoader = ::leptos::wasm_split_helpers::LazySplitLoader::new(#load_module_ident);
+ }
+
+ #[link(wasm_import_module = "/pkg/__wasm_split.js")]
+ extern "C" {
+ #[no_mangle]
+ fn #load_module_ident (callback: unsafe extern "C" fn(*const ::std::ffi::c_void, bool), data: *const ::std::ffi::c_void) -> ();
+
+ #[allow(improper_ctypes)]
+ #[no_mangle]
+ #import_sig;
+ }
+
+ #[allow(non_snake_case)]
+ #wrapper_sig {
+ #(#attrs)*
+ #[allow(improper_ctypes_definitions)]
+ #[allow(non_snake_case)]
+ #[no_mangle]
+ pub extern "C" #export_sig {
+ #body
+ }
+
+ ::leptos::wasm_split_helpers::ensure_loaded(split_loader_ident).await.unwrap();
+ unsafe { #impl_import_ident( #(#args),* ) } #await_result
+ }
+
+ #[doc(hidden)]
+ #[allow(non_snake_case)]
+ pub async fn #preload_name() {
+ ::leptos::wasm_split_helpers::ensure_loaded(split_loader_ident).await.unwrap();
+ }
+ }
+ .into()
+}