mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-27 15:44:42 -05:00
Compare commits
144 Commits
3872
...
lazy-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efaac6a94 | ||
|
|
972e9c1d1c | ||
|
|
3a6c2fc547 | ||
|
|
e679b72ebb | ||
|
|
f5d3fbb091 | ||
|
|
fbe7cdc482 | ||
|
|
14884bc8ac | ||
|
|
2c93e1a185 | ||
|
|
64b8c3dfd5 | ||
|
|
5f2d511553 | ||
|
|
d7cdc6c489 | ||
|
|
ebb33b6f41 | ||
|
|
809c0b532c | ||
|
|
b13f2420fb | ||
|
|
77de264615 | ||
|
|
1524386346 | ||
|
|
426b079709 | ||
|
|
c6f176e2b0 | ||
|
|
75662d08e7 | ||
|
|
4448b77cde | ||
|
|
956af8e466 | ||
|
|
8c469b85d6 | ||
|
|
7f93dd224d | ||
|
|
777b5e1e54 | ||
|
|
37cb102d53 | ||
|
|
433f7284e6 | ||
|
|
897e6ecc26 | ||
|
|
0c67f7d389 | ||
|
|
232b603a25 | ||
|
|
4a8a212d84 | ||
|
|
1d7bc021af | ||
|
|
74055a7e13 | ||
|
|
c98082de74 | ||
|
|
b8d44e20a9 | ||
|
|
00e83e0d70 | ||
|
|
e89b1389ca | ||
|
|
bd454d03e2 | ||
|
|
d7f4457ea4 | ||
|
|
17d357bcec | ||
|
|
66d1bead9a | ||
|
|
69c918e813 | ||
|
|
2817a261ce | ||
|
|
972b1ff90b | ||
|
|
10c13bbca2 | ||
|
|
e545b7c48a | ||
|
|
839eb9ac1c | ||
|
|
ae9324e555 | ||
|
|
3a66a1f3d3 | ||
|
|
f7c4a664d2 | ||
|
|
d446474456 | ||
|
|
d7bc6715a6 | ||
|
|
4c95cddca8 | ||
|
|
437d61bed7 | ||
|
|
3fdbae4314 | ||
|
|
7559b27361 | ||
|
|
b9bb14cfdc | ||
|
|
504f983996 | ||
|
|
c7a319db15 | ||
|
|
0862385816 | ||
|
|
0d18da720b | ||
|
|
12f5676bd1 | ||
|
|
0fa8155adc | ||
|
|
f8fa6de987 | ||
|
|
81b37a3867 | ||
|
|
8319446d3f | ||
|
|
efb1e945d9 | ||
|
|
5fa31941bb | ||
|
|
f4bb87ea1e | ||
|
|
016fbf8da1 | ||
|
|
21fd995468 | ||
|
|
17b9bec79a | ||
|
|
acd69daedb | ||
|
|
b8a3129396 | ||
|
|
783b4c4b04 | ||
|
|
683e7177dd | ||
|
|
eede2e9e6c | ||
|
|
31d51ea94f | ||
|
|
01fbd82edf | ||
|
|
b276e703a8 | ||
|
|
d2409a22a7 | ||
|
|
f6cd784088 | ||
|
|
eb9ebc870f | ||
|
|
b746c2ac4e | ||
|
|
4c1e7dc8c1 | ||
|
|
f1fa4635c7 | ||
|
|
46c8a11eae | ||
|
|
6b72ce3c16 | ||
|
|
33b278c014 | ||
|
|
5fc56346f4 | ||
|
|
afb37aaf4b | ||
|
|
f8fd79725a | ||
|
|
131251b361 | ||
|
|
91fb315fe0 | ||
|
|
6954b77b62 | ||
|
|
299a4c161f | ||
|
|
b0ee946412 | ||
|
|
b505892568 | ||
|
|
b63cfa7935 | ||
|
|
01a939e1e4 | ||
|
|
995bc60c74 | ||
|
|
4c4869d33c | ||
|
|
0ca8d32805 | ||
|
|
0d853fdb74 | ||
|
|
853f049d9f | ||
|
|
77176f8395 | ||
|
|
84136cafa5 | ||
|
|
bb3f1deb1f | ||
|
|
344b79a01b | ||
|
|
051059c761 | ||
|
|
3c540dd858 | ||
|
|
4125688a0a | ||
|
|
bd3b962cfb | ||
|
|
5dd3c217c4 | ||
|
|
ae00e5ae13 | ||
|
|
1ce671ba08 | ||
|
|
ec9f26bd9f | ||
|
|
831eae31bc | ||
|
|
ff6ae5de25 | ||
|
|
c21712ba04 | ||
|
|
45771b6fd3 | ||
|
|
f3557970a7 | ||
|
|
c87ef331b0 | ||
|
|
e767518142 | ||
|
|
f94b681118 | ||
|
|
9c50e49253 | ||
|
|
57c7097ede | ||
|
|
1a06e0eee8 | ||
|
|
ce9af4a685 | ||
|
|
e0c79eb8d8 | ||
|
|
9fd972971e | ||
|
|
9473220639 | ||
|
|
ae11812dc6 | ||
|
|
4c55c25445 | ||
|
|
5d9df592d5 | ||
|
|
323de496f3 | ||
|
|
c8df5b75ef | ||
|
|
89cbf86595 | ||
|
|
b78a6655f3 | ||
|
|
b5797ffe6a | ||
|
|
775e2eabed | ||
|
|
37405ec778 | ||
|
|
54890af875 | ||
|
|
5479ece865 | ||
|
|
f0b7e7445b |
8
.github/workflows/autofix.yml
vendored
8
.github/workflows/autofix.yml
vendored
@@ -21,7 +21,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with: {toolchain: "nightly-2025-04-16", components: "rustfmt, clippy", target: "wasm32-unknown-unknown", rustflags: ""}
|
||||
with:
|
||||
{
|
||||
toolchain: "nightly-2025-07-16",
|
||||
components: "rustfmt, clippy",
|
||||
target: "wasm32-unknown-unknown",
|
||||
rustflags: "",
|
||||
}
|
||||
- name: Install Glib
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
6
.github/workflows/run-cargo-make-task.yml
vendored
6
.github/workflows/run-cargo-make-task.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
toolchain: [stable, nightly-2025-04-16]
|
||||
toolchain: [stable, nightly-2025-07-16]
|
||||
erased_mode: [true, false]
|
||||
steps:
|
||||
- name: Free Disk Space
|
||||
@@ -169,7 +169,9 @@ jobs:
|
||||
cd '${{ inputs.directory }}'
|
||||
cargo make --no-workspace --profile=github-actions ci
|
||||
# check the direct-minimal-versions on release
|
||||
if [[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
COMMIT_MSG=$(git log -1 --pretty=format:'%s')
|
||||
# Supports: v1.2.3, v1.2.3-alpha, v1.2.3-beta1, v1.2.3-rc.1, etc.
|
||||
if [[ "$COMMIT_MSG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.?[0-9]+)?)?$ ]]; then
|
||||
cargo make --no-workspace --profile=github-actions check-minimal-versions
|
||||
fi
|
||||
# Check if the counter_isomorphic can be built with leptos_debuginfo cfg flag in release mode
|
||||
|
||||
583
Cargo.lock
generated
583
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
66
Cargo.toml
66
Cargo.toml
@@ -40,43 +40,46 @@ members = [
|
||||
exclude = ["benchmarks", "examples", "projects"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.2"
|
||||
version = "0.8.5"
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
rust-version = "1.88"
|
||||
|
||||
[workspace.dependencies]
|
||||
# members
|
||||
throw_error = { path = "./any_error/", version = "0.3.0" }
|
||||
any_spawner = { path = "./any_spawner/", version = "0.3.0" }
|
||||
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1" }
|
||||
either_of = { path = "./either_of/", version = "0.1.5" }
|
||||
either_of = { path = "./either_of/", version = "0.1.6" }
|
||||
hydration_context = { path = "./hydration_context", version = "0.3.0" }
|
||||
leptos = { path = "./leptos", version = "0.8.2" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.8.2" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.8.2" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.8.2" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.8.2" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.8.2" }
|
||||
leptos_router = { path = "./router", version = "0.8.2" }
|
||||
leptos_router_macro = { path = "./router_macro", version = "0.8.2" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.8.2" }
|
||||
leptos_meta = { path = "./meta", version = "0.8.2" }
|
||||
leptos = { path = "./leptos", version = "0.8.5" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.8.5" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.8.5" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.8.5" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.8.5" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.8.5" }
|
||||
leptos_router = { path = "./router", version = "0.8.5" }
|
||||
leptos_router_macro = { path = "./router_macro", version = "0.8.5" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.8.5" }
|
||||
leptos_meta = { path = "./meta", version = "0.8.5" }
|
||||
next_tuple = { path = "./next_tuple", version = "0.1.0" }
|
||||
oco_ref = { path = "./oco", version = "0.2.0" }
|
||||
oco_ref = { path = "./oco", version = "0.2.1" }
|
||||
or_poisoned = { path = "./or_poisoned", version = "0.1.0" }
|
||||
reactive_graph = { path = "./reactive_graph", version = "0.2.0" }
|
||||
reactive_stores = { path = "./reactive_stores", version = "0.2.0" }
|
||||
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.2.0" }
|
||||
server_fn = { path = "./server_fn", version = "0.8.2" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.8.2" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.8.2" }
|
||||
tachys = { path = "./tachys", version = "0.2.0" }
|
||||
reactive_graph = { path = "./reactive_graph", version = "0.2.5" }
|
||||
reactive_stores = { path = "./reactive_stores", version = "0.2.5" }
|
||||
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.2.5" }
|
||||
server_fn = { path = "./server_fn", version = "0.8.5" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.8.5" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.8.5" }
|
||||
tachys = { path = "./tachys", version = "0.2.6" }
|
||||
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" }
|
||||
trybuild = { default-features = false, version = "1.0.105" }
|
||||
trybuild = { default-features = false, version = "1.0.106" }
|
||||
typed-builder = { default-features = false, version = "0.21.0" }
|
||||
thiserror = { default-features = false, version = "2.0.12" }
|
||||
wasm-bindgen = { default-features = false, version = "0.2.100" }
|
||||
@@ -98,7 +101,7 @@ proc-macro-error2 = { default-features = false, version = "2.0.1" }
|
||||
const_format = { default-features = false, version = "0.2.34" }
|
||||
gloo-net = { default-features = false, version = "0.6.0" }
|
||||
url = { default-features = false, version = "2.5.4" }
|
||||
tokio = { default-features = false, version = "1.45.1" }
|
||||
tokio = { default-features = false, version = "1.46.1" }
|
||||
base64 = { default-features = false, version = "0.22.1" }
|
||||
cfg-if = { default-features = false, version = "1.0.0" }
|
||||
wasm-bindgen-futures = { default-features = false, version = "0.4.50" }
|
||||
@@ -108,7 +111,7 @@ serde = { default-features = false, version = "1.0.219" }
|
||||
parking_lot = { default-features = false, version = "0.12.4" }
|
||||
axum = { default-features = false, version = "0.8.4" }
|
||||
serde_qs = { default-features = false, version = "0.15.0" }
|
||||
syn = { default-features = false, version = "2.0.101" }
|
||||
syn = { default-features = false, version = "2.0.104" }
|
||||
xxhash-rust = { default-features = false, version = "0.8.15" }
|
||||
paste = { default-features = false, version = "1.0.15" }
|
||||
quote = { default-features = false, version = "1.0.40" }
|
||||
@@ -116,10 +119,10 @@ web-sys = { default-features = false, version = "0.3.77" }
|
||||
js-sys = { default-features = false, version = "0.3.77" }
|
||||
rand = { default-features = false, version = "0.9.1" }
|
||||
serde-lite = { default-features = false, version = "0.5.0" }
|
||||
tokio-tungstenite = { default-features = false, version = "0.26.2" }
|
||||
tokio-tungstenite = { default-features = false, version = "0.27.0" }
|
||||
serial_test = { default-features = false, version = "3.2.0" }
|
||||
erased = { default-features = false, version = "0.1.2" }
|
||||
glib = { default-features = false, version = "0.20.10" }
|
||||
glib = { default-features = false, version = "0.20.12" }
|
||||
async-trait = { default-features = false, version = "0.1.88" }
|
||||
typed-builder-macro = { default-features = false, version = "0.21.0" }
|
||||
linear-map = { default-features = false, version = "1.2.0" }
|
||||
@@ -127,9 +130,9 @@ anyhow = { default-features = false, version = "1.0.98" }
|
||||
walkdir = { default-features = false, version = "2.5.0" }
|
||||
actix-ws = { default-features = false, version = "0.3.0" }
|
||||
tower-http = { default-features = false, version = "0.6.4" }
|
||||
prettyplease = { default-features = false, version = "0.2.33" }
|
||||
prettyplease = { default-features = false, version = "0.2.35" }
|
||||
inventory = { default-features = false, version = "0.3.20" }
|
||||
config = { default-features = false, version = "0.15.11" }
|
||||
config = { default-features = false, version = "0.15.13" }
|
||||
camino = { default-features = false, version = "1.1.9" }
|
||||
ciborium = { default-features = false, version = "0.2.2" }
|
||||
multer = { default-features = false, version = "3.1.0" }
|
||||
@@ -149,12 +152,12 @@ futures-lite = { default-features = false, version = "2.6.0" }
|
||||
log = { default-features = false, version = "0.4.27" }
|
||||
percent-encoding = { default-features = false, version = "2.3.1" }
|
||||
async-executor = { default-features = false, version = "1.13.2" }
|
||||
const-str = { default-features = false, version = "0.6.2" }
|
||||
const-str = { default-features = false, version = "0.6.3" }
|
||||
http-body-util = { default-features = false, version = "0.1.3" }
|
||||
hyper = { default-features = false, version = "1.6.0" }
|
||||
postcard = { default-features = false, version = "1.1.1" }
|
||||
rmp-serde = { default-features = false, version = "1.3.0" }
|
||||
reqwest = { default-features = false, version = "0.12.18" }
|
||||
reqwest = { default-features = false, version = "0.12.22" }
|
||||
tower-layer = { default-features = false, version = "0.3.3" }
|
||||
attribute-derive = { default-features = false, version = "0.10.3" }
|
||||
insta = { default-features = false, version = "1.43.1" }
|
||||
@@ -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
|
||||
|
||||
26
README.md
26
README.md
@@ -90,35 +90,13 @@ Here are some resources for learning more about Leptos:
|
||||
- [API Documentation](https://docs.rs/leptos/latest/leptos/)
|
||||
- [Common Bugs](https://github.com/leptos-rs/leptos/tree/main/docs/COMMON_BUGS.md) (and how to fix them!)
|
||||
|
||||
## `nightly` Note
|
||||
|
||||
Most of the examples assume you’re using `nightly` version of Rust and the `nightly` feature of Leptos. To use `nightly` Rust, you can either set your toolchain globally or on per-project basis.
|
||||
|
||||
To set `nightly` as a default toolchain for all projects (and add the ability to compile Rust to WebAssembly, if you haven’t already):
|
||||
|
||||
```
|
||||
rustup toolchain install nightly
|
||||
rustup default nightly
|
||||
rustup target add wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
If you'd like to use `nightly` only in your Leptos project however, add [`rust-toolchain.toml`](https://rust-lang.github.io/rustup/overrides.html#the-toolchain-file) file with the following content:
|
||||
|
||||
```toml
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
```
|
||||
|
||||
The `nightly` feature enables the function call syntax for accessing and setting signals, as opposed to `.get()` and `.set()`. This leads to a consistent mental model in which accessing a reactive value of any kind (a signal, memo, or derived signal) is always represented as a function call. This is only possible with nightly Rust and the `nightly` feature.
|
||||
|
||||
## `cargo-leptos`
|
||||
|
||||
[`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos) is a build tool that's designed to make it easy to build apps that run on both the client and the server, with seamless integration. The best way to get started with a real Leptos project right now is to use `cargo-leptos` and our starter templates for [Actix](https://github.com/leptos-rs/start) or [Axum](https://github.com/leptos-rs/start-axum).
|
||||
|
||||
```bash
|
||||
cargo install cargo-leptos
|
||||
cargo leptos new --git https://github.com/leptos-rs/start
|
||||
cargo leptos new --git https://github.com/leptos-rs/start-axum
|
||||
cd [your project name]
|
||||
cargo leptos watch
|
||||
```
|
||||
@@ -147,7 +125,7 @@ Yes, I’m sure there are. You can see from the state of our issue tracker over
|
||||
|
||||
This may be the big one: “production ready” implies a certain orientation to a library: that you can basically use it, without any special knowledge of its internals or ability to contribute. Everyone has this at some level in their stack: for example I (@gbj) don’t have the capacity or knowledge to contribute to something like `wasm-bindgen` at this point: I simply rely on it to work.
|
||||
|
||||
There are several people in the community using Leptos right now for internal apps at work, who have also become significant contributors. I think this is the right level of production use for now. There may be missing features that you need, and you may end up building them! But for internal apps, if you’re willing to build and contribute missing pieces along the way, the framework is definitely usable right now.
|
||||
There are several people in the community using Leptos right now for many websites at work, who have also become significant contributors. There may be missing features that you need, and you may end up building them! But, if you're willing to contribute a few missing pieces along the way, the framework is most definitely usable for production applications, especially given the ecosystem of libraries that have sprung up around it.
|
||||
|
||||
### Can I use this for native GUI?
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
name = "benchmarks"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[dependencies]
|
||||
l0410 = { package = "leptos", version = "0.4.10", features = [
|
||||
|
||||
@@ -19,7 +19,7 @@ leptos_meta = { path = "../../meta" }
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.39", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
|
||||
11
examples/cargo-make/cargo-leptos-split-webdriver-test.toml
Normal file
11
examples/cargo-make/cargo-leptos-split-webdriver-test.toml
Normal file
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
name = "counter_isomorphic"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
@@ -18,7 +18,7 @@ tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.39", features = ["full"], optional = true }
|
||||
http = { version = "1.1" }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
wasm-bindgen = "0.2.93"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -15,7 +15,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4.22"
|
||||
console_log = "1.0"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-subscriber-wasm = "0.1.0"
|
||||
|
||||
@@ -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 {
|
||||
<Nav />
|
||||
<main>
|
||||
<FlatRoutes fallback=|| "Not found.">
|
||||
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
|
||||
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
|
||||
<Route path=(StaticSegment("users"), ParamSegment("id")) view={Lazy::<UserRoute>::new()}/>
|
||||
<Route path=(StaticSegment("stories"), ParamSegment("id")) view={Lazy::<StoryRoute>::new()}/>
|
||||
<Route path=OptionalParamSegment("stories") view=Stories/>
|
||||
</FlatRoutes>
|
||||
</main>
|
||||
|
||||
@@ -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>(&api::story(&format!("item/{id}")))
|
||||
#[derive(Debug)]
|
||||
pub struct StoryRoute {
|
||||
story: Resource<Option<Story>>,
|
||||
}
|
||||
|
||||
#[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>(&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]
|
||||
|
||||
@@ -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::<User>(&api::user(&id)).await
|
||||
}
|
||||
},
|
||||
);
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || Suspend::new(async move { match user.await.clone() {
|
||||
None => Either::Left(view! { <h1>"User not found."</h1> }),
|
||||
Some(user) => Either::Right(view! {
|
||||
<div>
|
||||
<h1>"User: " {user.id.clone()}</h1>
|
||||
<ul class="meta">
|
||||
<li>
|
||||
<span class="label">"Created: "</span> {user.created}
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">"Karma: "</span> {user.karma}
|
||||
</li>
|
||||
<li inner_html={user.about} class="about"></li>
|
||||
</ul>
|
||||
<p class="links">
|
||||
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
|
||||
" | "
|
||||
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
|
||||
</p>
|
||||
</div>
|
||||
})
|
||||
}})}
|
||||
</Suspense>
|
||||
</div>
|
||||
#[derive(Debug)]
|
||||
pub struct UserRoute {
|
||||
user: Resource<Option<User>>,
|
||||
}
|
||||
|
||||
#[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::<User>(&api::user(&id)).await
|
||||
}
|
||||
},
|
||||
);
|
||||
UserRoute { user }
|
||||
}
|
||||
|
||||
fn view(this: Self) -> AnyView {
|
||||
let UserRoute { user } = this;
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || Suspend::new(async move { match user.await.clone() {
|
||||
None => Either::Left(view! { <h1>"User not found."</h1> }),
|
||||
Some(user) => Either::Right(view! {
|
||||
<div>
|
||||
<h1>"User: " {user.id.clone()}</h1>
|
||||
<ul class="meta">
|
||||
<li>
|
||||
<span class="label">"Created: "</span> {user.created}
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">"Karma: "</span> {user.karma}
|
||||
</li>
|
||||
<li inner_html={user.about} class="about"></li>
|
||||
</ul>
|
||||
<p class="links">
|
||||
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
|
||||
" | "
|
||||
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
|
||||
</p>
|
||||
</div>
|
||||
})
|
||||
}})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
95
examples/lazy_routes/Cargo.toml
Normal file
95
examples/lazy_routes/Cargo.toml
Normal file
@@ -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
|
||||
21
examples/lazy_routes/LICENSE
Normal file
21
examples/lazy_routes/LICENSE
Normal file
@@ -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.
|
||||
8
examples/lazy_routes/Makefile.toml
Normal file
8
examples/lazy_routes/Makefile.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos-split-webdriver-test.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
|
||||
CLIENT_PROCESS_NAME = "regression"
|
||||
8
examples/lazy_routes/README.md
Normal file
8
examples/lazy_routes/README.md
Normal file
@@ -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.
|
||||
BIN
examples/lazy_routes/assets/favicon.ico
Normal file
BIN
examples/lazy_routes/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
18
examples/lazy_routes/e2e/Cargo.toml
Normal file
18
examples/lazy_routes/e2e/Cargo.toml
Normal file
@@ -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
|
||||
20
examples/lazy_routes/e2e/Makefile.toml
Normal file
20
examples/lazy_routes/e2e/Makefile.toml
Normal file
@@ -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",
|
||||
"${@}",
|
||||
]
|
||||
30
examples/lazy_routes/e2e/README.md
Normal file
30
examples/lazy_routes/e2e/README.md
Normal file
@@ -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.
|
||||
33
examples/lazy_routes/e2e/features/basic.feature
Normal file
33
examples/lazy_routes/e2e/features/basic.feature
Normal file
@@ -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
|
||||
15
examples/lazy_routes/e2e/features/duplicate_name.feature
Normal file
15
examples/lazy_routes/e2e/features/duplicate_name.feature
Normal file
@@ -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.
|
||||
9
examples/lazy_routes/e2e/features/shared_chunks.feature
Normal file
9
examples/lazy_routes/e2e/features/shared_chunks.feature
Normal file
@@ -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}
|
||||
30
examples/lazy_routes/e2e/tests/app_suite.rs
Normal file
30
examples/lazy_routes/e2e/tests/app_suite.rs
Normal file
@@ -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(())
|
||||
}
|
||||
23
examples/lazy_routes/e2e/tests/fixtures/action.rs
vendored
Normal file
23
examples/lazy_routes/e2e/tests/fixtures/action.rs
vendored
Normal file
@@ -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(())
|
||||
}
|
||||
29
examples/lazy_routes/e2e/tests/fixtures/check.rs
vendored
Normal file
29
examples/lazy_routes/e2e/tests/fixtures/check.rs
vendored
Normal file
@@ -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(())
|
||||
}
|
||||
23
examples/lazy_routes/e2e/tests/fixtures/find.rs
vendored
Normal file
23
examples/lazy_routes/e2e/tests/fixtures/find.rs
vendored
Normal file
@@ -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<String> {
|
||||
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<Element> {
|
||||
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<Element> {
|
||||
Ok(client.wait().for_element(Locator::Id(id)).await?)
|
||||
}
|
||||
4
examples/lazy_routes/e2e/tests/fixtures/mod.rs
vendored
Normal file
4
examples/lazy_routes/e2e/tests/fixtures/mod.rs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod action;
|
||||
pub mod check;
|
||||
pub mod find;
|
||||
pub mod world;
|
||||
68
examples/lazy_routes/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
68
examples/lazy_routes/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
@@ -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(())
|
||||
}
|
||||
31
examples/lazy_routes/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
31
examples/lazy_routes/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
@@ -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(())
|
||||
}
|
||||
39
examples/lazy_routes/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
39
examples/lazy_routes/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
@@ -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<Self, anyhow::Error> {
|
||||
let webdriver_client = build_client().await?;
|
||||
|
||||
Ok(Self {
|
||||
client: webdriver_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_client() -> Result<Client, NewSessionError> {
|
||||
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)
|
||||
}
|
||||
355
examples/lazy_routes/src/app.rs
Normal file
355
examples/lazy_routes/src/app.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
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! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<AutoReload options=options.clone()/>
|
||||
<HydrationScripts options/>
|
||||
</head>
|
||||
<body>
|
||||
<App/>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
let count = RwSignal::new(0);
|
||||
provide_context(count);
|
||||
let (is_routing, set_is_routing) = signal(false);
|
||||
|
||||
view! {
|
||||
<nav id="nav" style="width: 100%">
|
||||
<a href="/">"A"</a> " | "
|
||||
<a href="/b">"B"</a> " | "
|
||||
<a href="/c">"C"</a> " | "
|
||||
<a href="/d">"D"</a>
|
||||
<span style="float: right" id="navigating">
|
||||
{move || is_routing.get().then_some("Navigating...")}
|
||||
</span>
|
||||
</nav>
|
||||
<Router set_is_routing>
|
||||
<Routes fallback=|| "Not found.">
|
||||
<Route path=StaticSegment("") view=ViewA/>
|
||||
<Route path=StaticSegment("b") view=ViewB/>
|
||||
<Route path=StaticSegment("c") view={Lazy::<ViewC>::new()}/>
|
||||
// you can nest lazy routes, and there data and views will all load concurrently
|
||||
<ParentRoute path=StaticSegment("d") view={Lazy::<ViewD>::new()}>
|
||||
<Route path=StaticSegment("") view={Lazy::<ViewE>::new()}/>
|
||||
</ParentRoute>
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
// 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! {
|
||||
<p id="page">"View A"</p>
|
||||
<pre id="result">{result}</pre>
|
||||
<button id="First" on:click=move |_| spawn_local(async move { result.set(first_value().await); })>"First"</button>
|
||||
<button id="Second" on:click=move |_| spawn_local(async move { result.set(second_value().await); })>"Second"</button>
|
||||
// test to make sure duplicate names in different scopes can be used
|
||||
<button id="Third" on:click=move |_| {
|
||||
#[lazy]
|
||||
pub fn second_value() -> String {
|
||||
"Third value.".to_string()
|
||||
}
|
||||
|
||||
spawn_local(async move {
|
||||
result.set(second_value().await);
|
||||
});
|
||||
}>"Third"</button>
|
||||
}
|
||||
}
|
||||
|
||||
// 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<Comment> {
|
||||
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! {
|
||||
<p id="page">"View B"</p>
|
||||
<Suspense fallback=|| view! { <p id="loading">"Loading..."</p> }>
|
||||
<ul>
|
||||
{move || Suspend::new(async move {
|
||||
let items = data.await;
|
||||
items.into_iter()
|
||||
.map(|comment| view! {
|
||||
<li id=format!("{}-{}", comment.post_id, comment.id)>
|
||||
<strong>{comment.name}</strong> " (by " {comment.email} ")"<br/>
|
||||
{comment.body}
|
||||
</li>
|
||||
})
|
||||
.collect_view()
|
||||
})}
|
||||
</ul>
|
||||
</Suspense>
|
||||
}
|
||||
.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<Vec<Album>>,
|
||||
}
|
||||
|
||||
// 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<Album> {
|
||||
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! {
|
||||
<li id=format!("{}-{}", album.user_id, album.id)>
|
||||
{album.title}
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
view! {
|
||||
<p id="page">"View C"</p>
|
||||
<hr/>
|
||||
<Suspense fallback=|| view! { <p id="loading">"Loading..."</p> }>
|
||||
<ul>{albums}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
.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<Result<Vec<i32>, 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! { <li>{item}</li> })
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
view! {
|
||||
<p id="page">"View D"</p>
|
||||
<hr/>
|
||||
<Suspense fallback=|| view! { <p id="loading">"Loading..."</p> }>
|
||||
<ul>{items}</ul>
|
||||
</Suspense>
|
||||
<Outlet/>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
// Server functions can be made lazy by combining the two macros,
|
||||
// with `#[server]` coming first, then `#[lazy]`
|
||||
#[server]
|
||||
#[lazy]
|
||||
async fn d_data() -> Result<Vec<i32>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
Ok(vec![1, 1, 2, 3, 5, 8, 13])
|
||||
}
|
||||
|
||||
struct ViewE {
|
||||
data: Resource<Result<Vec<String>, 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! { <li>{item}</li> })
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
view! {
|
||||
<p id="page">"View E"</p>
|
||||
<hr/>
|
||||
<Suspense fallback=|| view! { <p id="loading">"Loading..."</p> }>
|
||||
<ul>{items}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn e_data() -> Result<Vec<String>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
Ok(vec!["foo".into(), "bar".into(), "baz".into()])
|
||||
}
|
||||
9
examples/lazy_routes/src/lib.rs
Normal file
9
examples/lazy_routes/src/lib.rs
Normal file
@@ -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);
|
||||
}
|
||||
37
examples/lazy_routes/src/main.rs
Normal file
37
examples/lazy_routes/src/main.rs
Normal file
@@ -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
|
||||
}
|
||||
3
examples/lazy_routes/style/main.scss
Normal file
3
examples/lazy_routes/style/main.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ leptos_meta = { path = "../../meta" }
|
||||
leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.39", features = [ "rt-multi-thread", "macros", "time" ], optional = true }
|
||||
wasm-bindgen = "0.2.92"
|
||||
|
||||
|
||||
20
examples/regression/e2e/features/issue_4088.feature
Normal file
20
examples/regression/e2e/features/issue_4088.feature
Normal file
@@ -0,0 +1,20 @@
|
||||
@check_issue_4088
|
||||
Feature: Check that issue 4088 does not reappear
|
||||
|
||||
Scenario: I can see the navbar
|
||||
Given I see the app
|
||||
And I can access regression test 4088
|
||||
Then I see the navbar
|
||||
|
||||
Scenario: The user info is shared via context
|
||||
Given I see the app
|
||||
And I can access regression test 4088
|
||||
When I select the link Class 1
|
||||
Then I see the result is the string Assignments for team of user with id 42
|
||||
|
||||
Scenario: The user info is shared via context
|
||||
Given I see the app
|
||||
And I can access regression test 4088
|
||||
When I select the link Class 1
|
||||
When I refresh the browser
|
||||
Then I see the result is the string Assignments for team of user with id 42
|
||||
8
examples/regression/e2e/features/pr_4015.feature
Normal file
8
examples/regression/e2e/features/pr_4015.feature
Normal file
@@ -0,0 +1,8 @@
|
||||
@check_pr_4015
|
||||
Feature: Check that PR 4015 does not regress
|
||||
|
||||
Scenario: The correct text appears
|
||||
Given I see the app
|
||||
And I can access regression test 4015
|
||||
Then I see the result is the string Some(42)
|
||||
|
||||
@@ -24,3 +24,25 @@ Feature: Regression from pull request 4091
|
||||
| test1 |
|
||||
| 4091 Home |
|
||||
Then I see the result is empty
|
||||
|
||||
Scenario: I can see the navbar
|
||||
Given I see the app
|
||||
And I can access regression test 4091
|
||||
Then I see the navbar
|
||||
|
||||
Scenario: If I navigate to home and back, I can still see the navbar
|
||||
Given I see the app
|
||||
And I can access regression test 4091
|
||||
When I select the following links
|
||||
| Home |
|
||||
| 4091 |
|
||||
Then I see the navbar
|
||||
|
||||
Scenario: The signal is not disposed too early
|
||||
Given I see the app
|
||||
And I can access regression test 4091
|
||||
When I select the following links
|
||||
| test1 |
|
||||
| Home |
|
||||
| 4091 |
|
||||
Then I see the navbar
|
||||
@@ -11,3 +11,10 @@ pub async fn result_text_is(
|
||||
assert_eq!(&actual, expected_text);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ use anyhow::{Ok, Result};
|
||||
use fantoccini::{elements::Element, Client, Locator};
|
||||
|
||||
pub async fn text_at_id(client: &Client, id: &str) -> Result<String> {
|
||||
let element = client
|
||||
.wait()
|
||||
.for_element(Locator::Id(id))
|
||||
let element = element_by_id(client, id)
|
||||
.await
|
||||
.expect(format!("no such element with id `{}`", id).as_str());
|
||||
let text = element.text().await?;
|
||||
@@ -19,3 +17,7 @@ pub async fn link_with_text(client: &Client, text: &str) -> Result<Element> {
|
||||
.expect(format!("Link not found by `{}`", text).as_str());
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
pub async fn element_by_id(client: &Client, id: &str) -> Result<Element> {
|
||||
Ok(client.wait().for_element(Locator::Id(id)).await?)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ use anyhow::{Ok, Result};
|
||||
use cucumber::then;
|
||||
|
||||
#[then(regex = r"^I see the result is empty$")]
|
||||
async fn i_see_the_result_is_empty(
|
||||
world: &mut AppWorld,
|
||||
) -> Result<()> {
|
||||
async fn i_see_the_result_is_empty(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::result_text_is(client, "").await?;
|
||||
Ok(())
|
||||
@@ -20,3 +18,10 @@ async fn i_see_the_result_is_the_string(
|
||||
check::result_text_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(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::pr_4091::Routes4091;
|
||||
use crate::{issue_4088::Routes4088, pr_4015::Routes4015, pr_4091::Routes4091};
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{MetaTags, *};
|
||||
use leptos_router::{
|
||||
@@ -35,6 +35,8 @@ pub fn App() -> impl IntoView {
|
||||
<Routes fallback>
|
||||
<Route path=path!("") view=HomePage/>
|
||||
<Routes4091/>
|
||||
<Routes4015/>
|
||||
<Routes4088/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
@@ -55,6 +57,8 @@ fn HomePage() -> impl IntoView {
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="/4091/">"4091"</a></li>
|
||||
<li><a href="/4015/">"4015"</a></li>
|
||||
<li><a href="/4088/">"4088"</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
|
||||
119
examples/regression/src/issue_4088.rs
Normal file
119
examples/regression/src/issue_4088.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use leptos::{either::Either, prelude::*};
|
||||
#[allow(unused_imports)]
|
||||
use leptos_router::{
|
||||
components::{Outlet, ParentRoute, Redirect, Route},
|
||||
path, MatchNestedRoutes, NavigateOptions,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[component]
|
||||
pub fn Routes4088() -> impl MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("4088") view=|| view!{ <LoggedIn/> }>
|
||||
<ParentRoute path=path!("") view=||view!{<AssignmentsSelector/>}>
|
||||
<Route path=path!("/:team_id") view=||view!{<AssignmentsForTeam/>} />
|
||||
<Route path=path!("") view=||view!{ <p>No class selected</p> }/>
|
||||
</ParentRoute>
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: usize,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_user_info() -> Result<Option<UserInfo>, ServerFnError> {
|
||||
Ok(Some(UserInfo { id: 42 }))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoggedIn() -> impl IntoView {
|
||||
let user_info_resource =
|
||||
Resource::new(|| (), move |_| async { get_user_info().await });
|
||||
|
||||
view! {
|
||||
|
||||
<Transition fallback=move || view!{
|
||||
"loading"
|
||||
}
|
||||
>
|
||||
{move || {
|
||||
user_info_resource.get()
|
||||
.map(|a|
|
||||
match a {
|
||||
Ok(Some(a)) => Either::Left(view! {
|
||||
<LoggedInContent user_info={a} />
|
||||
}),
|
||||
_ => Either::Right(view!{
|
||||
<Redirect path="/not_logged_in"/>
|
||||
})
|
||||
})
|
||||
}}
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
/// Component which provides UserInfo and renders it's child
|
||||
/// Can also contain some code to check for specific situations (e.g. privacy policies accepted or not? redirect if needed...)
|
||||
pub fn LoggedInContent(user_info: UserInfo) -> impl IntoView {
|
||||
provide_context(user_info.clone());
|
||||
|
||||
if user_info.id == 42 {
|
||||
Either::Left(Outlet())
|
||||
} else {
|
||||
Either::Right(
|
||||
view! { <Redirect path="/somewhere" options={NavigateOptions::default()}/> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
/// This component also uses Outlet (so nested Outlet)
|
||||
fn AssignmentsSelector() -> impl IntoView {
|
||||
let user_info = use_context::<UserInfo>().expect("user info not provided");
|
||||
|
||||
view! {
|
||||
<p>"Assignments for user with ID: "{user_info.id}</p>
|
||||
<ul id="nav">
|
||||
<li><a href="/4088/1">"Class 1"</a></li>
|
||||
<li><a href="/4088/2">"Class 2"</a></li>
|
||||
<li><a href="/4088/3">"Class 3"</a></li>
|
||||
</ul>
|
||||
|
||||
<Outlet />
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AssignmentsForTeam() -> impl IntoView {
|
||||
// THIS FAILS -> Because of the nested outlet in LoggedInContent > AssignmentsSelector?
|
||||
// It did not fail when LoggedIn did not use a resource and transition (but a hardcoded UserInfo in the component)
|
||||
let user_info = use_context::<UserInfo>().expect("user info not provided");
|
||||
|
||||
let items = vec!["Assignment 1", "Assignment 2", "Assignment 3"];
|
||||
view! {
|
||||
<p id="result">"Assignments for team of user with id " {user_info.id}</p>
|
||||
<ul>
|
||||
{
|
||||
items.into_iter().map(|item| {
|
||||
view! {
|
||||
<Assignment name=item.to_string() />
|
||||
}
|
||||
}).collect_view()
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Assignment(name: String) -> impl IntoView {
|
||||
let user_info = use_context::<UserInfo>().expect("user info not provided");
|
||||
|
||||
view! {
|
||||
<li>{name}" "{user_info.id}</li>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod app;
|
||||
mod issue_4088;
|
||||
mod pr_4015;
|
||||
mod pr_4091;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
||||
29
examples/regression/src/pr_4015.rs
Normal file
29
examples/regression/src/pr_4015.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use leptos::{context::Provider, prelude::*};
|
||||
use leptos_router::{
|
||||
components::{ParentRoute, Route},
|
||||
nested_router::Outlet,
|
||||
path,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Routes4015() -> impl leptos_router::MatchNestedRoutes + Clone {
|
||||
view! {
|
||||
<ParentRoute path=path!("4015") view=|| view! {
|
||||
<Provider value=42i32>
|
||||
<Outlet/>
|
||||
</Provider>
|
||||
}>
|
||||
<Route path=path!("") view=Child/>
|
||||
</ParentRoute>
|
||||
}
|
||||
.into_inner()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Child() -> impl IntoView {
|
||||
let value = use_context::<i32>();
|
||||
|
||||
view! {
|
||||
<p id="result">{format!("{value:?}")}</p>
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ fn Container() -> impl IntoView {
|
||||
provide_context(rw_signal);
|
||||
|
||||
view! {
|
||||
<nav>
|
||||
<nav id="nav">
|
||||
<ul>
|
||||
<li><A href="/">"Home"</A></li>
|
||||
<li><A href="./">"4091 Home"</A></li>
|
||||
<li><A href="test1">"test1"</A></li>
|
||||
</ul>
|
||||
|
||||
@@ -29,7 +29,7 @@ tower-http = { version = "0.6.2", features = [
|
||||
"trace",
|
||||
], optional = true }
|
||||
tokio = { version = "1.39", features = ["full"], optional = true }
|
||||
thiserror = "2.0.11"
|
||||
thiserror = "2.0.12"
|
||||
wasm-bindgen = "0.2.93"
|
||||
serde_toml = "0.0.1"
|
||||
toml = "0.8.19"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
name = "ssr_modes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -19,7 +17,7 @@ leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.39", features = ["time"] }
|
||||
wasm-bindgen = "0.2.93"
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
name = "ssr_modes_axum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
# std::sync::LazyLock is stabilized in Rust version 1.80.0
|
||||
rust-version = "1.80.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
@@ -19,7 +17,7 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
axum = { version = "0.8.1", optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
|
||||
|
||||
@@ -17,7 +17,7 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
axum = { version = "0.8.1", optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
|
||||
|
||||
@@ -159,7 +159,7 @@ fn TodoRow(
|
||||
|
||||
view! {
|
||||
<li style:text-decoration=move || {
|
||||
status.done().then_some("line-through").unwrap_or_default()
|
||||
if status.done() { "line-through" } else { Default::default() }
|
||||
}>
|
||||
|
||||
<p
|
||||
|
||||
@@ -20,7 +20,7 @@ tokio = { version = "1.39", features = [
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
|
||||
wasm-bindgen = "0.2.93"
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
tracing = { version = "0.1.40", optional = true }
|
||||
http = "1.1"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
log = "0.4.22"
|
||||
simple_logger = "5.0"
|
||||
gloo = { git = "https://github.com/rustwasm/gloo" }
|
||||
sqlx = { version = "0.8.0", features = [
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
@@ -44,12 +44,12 @@ denylist = ["actix-files", "actix-web", "leptos_actix", "sqlx"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"], []]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "todo_app_sqlite"
|
||||
# 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
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "./style.css"
|
||||
|
||||
@@ -20,11 +20,11 @@ axum = { version = "0.8.1", optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.39", features = ["full"], optional = true }
|
||||
sqlx = { version = "0.8.0", features = [
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "1.0"
|
||||
thiserror = "2.0.12"
|
||||
wasm-bindgen = "0.2.93"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -20,11 +20,11 @@ tower = { version = "0.5.1", features = ["util"], optional = true }
|
||||
tower-http = { version = "0.6.1", features = ["fs"], optional = true }
|
||||
tokio = { version = "1.39", features = ["full"], optional = true }
|
||||
http = { version = "1.1" }
|
||||
sqlx = { version = "0.8.0", features = [
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"sqlite",
|
||||
], optional = true }
|
||||
thiserror = "2.0"
|
||||
thiserror = "2.0.12"
|
||||
wasm-bindgen = "0.2.93"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -17,7 +17,7 @@ simple_logger = "5.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
axum = { version = "0.8.1", optional = true }
|
||||
tokio = { version = "1.39", features = ["full"], optional = true }
|
||||
thiserror = "2.0"
|
||||
thiserror = "2.0.12"
|
||||
wasm-bindgen = "0.2.100"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -11,7 +11,7 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
actix-http = { workspace = true, default-features = true }
|
||||
actix-files = { workspace = true, default-features = true }
|
||||
actix-web = { workspace = true, default-features = true }
|
||||
actix-web = { workspace = true, default-features = false }
|
||||
futures = { workspace = true, default-features = true }
|
||||
any_spawner = { workspace = true, features = ["tokio"] }
|
||||
hydration_context = { workspace = true }
|
||||
@@ -20,7 +20,7 @@ leptos_integration_utils = { workspace = true }
|
||||
leptos_macro = { workspace = true, features = ["actix"] }
|
||||
leptos_meta = { workspace = true, features = ["nonce"] }
|
||||
leptos_router = { workspace = true, features = ["ssr"] }
|
||||
server_fn = { workspace = true, features = ["actix"] }
|
||||
server_fn = { workspace = true, features = ["actix-no-default"] }
|
||||
tachys = { workspace = true }
|
||||
serde_json = { workspace = true , default-features = true }
|
||||
parking_lot = { workspace = true, default-features = true }
|
||||
@@ -33,6 +33,8 @@ dashmap = { workspace = true, default-features = true }
|
||||
rustdoc-args = ["--generate-link-to-definition"]
|
||||
|
||||
[features]
|
||||
default = ["actix-default"]
|
||||
actix-default = ["actix-web/default"]
|
||||
islands-router = ["tachys/islands"]
|
||||
tracing = ["dep:tracing"]
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ pub fn redirect(path: &str) {
|
||||
/// // call ServerFn::register() for each of the server functions you've defined
|
||||
/// }
|
||||
///
|
||||
/// # #[cfg(feature = "default")]
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() -> std::io::Result<()> {
|
||||
/// // make sure you actually register your server functions
|
||||
@@ -297,6 +298,8 @@ pub fn redirect(path: &str) {
|
||||
/// .run()
|
||||
/// .await
|
||||
/// }
|
||||
/// # #[cfg(not(feature = "default"))]
|
||||
/// # fn main() {}
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
@@ -442,6 +445,7 @@ pub fn handle_server_fns_with_context(
|
||||
/// view! { <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # #[cfg(feature = "default")]
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() -> std::io::Result<()> {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).unwrap();
|
||||
@@ -461,6 +465,8 @@ pub fn handle_server_fns_with_context(
|
||||
/// .run()
|
||||
/// .await
|
||||
/// }
|
||||
/// # #[cfg(not(feature = "default"))]
|
||||
/// # fn main() {}
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
@@ -499,6 +505,7 @@ where
|
||||
/// view! { <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # #[cfg(feature = "default")]
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() -> std::io::Result<()> {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).unwrap();
|
||||
@@ -521,6 +528,9 @@ where
|
||||
/// .run()
|
||||
/// .await
|
||||
/// }
|
||||
///
|
||||
/// # #[cfg(not(feature = "default"))]
|
||||
/// # fn main() {}
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
@@ -557,6 +567,7 @@ where
|
||||
/// view! { <main>"Hello, world!"</main> }
|
||||
/// }
|
||||
///
|
||||
/// # #[cfg(feature = "default")]
|
||||
/// #[actix_web::main]
|
||||
/// async fn main() -> std::io::Result<()> {
|
||||
/// let conf = get_configuration(Some("Cargo.toml")).unwrap();
|
||||
@@ -576,6 +587,8 @@ where
|
||||
/// .run()
|
||||
/// .await
|
||||
/// }
|
||||
/// # #[cfg(not(feature = "default"))]
|
||||
/// # fn main() {}
|
||||
/// ```
|
||||
///
|
||||
/// ## Provided Context Types
|
||||
|
||||
@@ -4,7 +4,7 @@ authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
description = "Axum integrations for the Leptos web framework."
|
||||
version = "0.8.2"
|
||||
version = { workspace = true }
|
||||
rust-version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -13,7 +13,7 @@ any_spawner = { workspace = true, features = ["tokio"] }
|
||||
hydration_context = { workspace = true }
|
||||
axum = { default-features = false, features = [
|
||||
"matched-path",
|
||||
] , workspace = true }
|
||||
], workspace = true }
|
||||
dashmap = { workspace = true, default-features = true }
|
||||
futures = { workspace = true, default-features = true }
|
||||
leptos = { workspace = true, features = ["nonce", "ssr"] }
|
||||
@@ -24,14 +24,17 @@ leptos_router = { workspace = true, features = ["ssr"] }
|
||||
leptos_integration_utils = { workspace = true }
|
||||
tachys = { workspace = true }
|
||||
parking_lot = { workspace = true, default-features = true }
|
||||
tokio = { default-features = false , workspace = true }
|
||||
tower = { features = ["util"] , workspace = true, default-features = true }
|
||||
tokio = { default-features = false, workspace = true }
|
||||
tower = { features = ["util"], workspace = true, default-features = true }
|
||||
tower-http = { workspace = true, default-features = true }
|
||||
tracing = { optional = true , workspace = true, default-features = true }
|
||||
tracing = { optional = true, workspace = true, default-features = true }
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true, default-features = true }
|
||||
tokio = { features = ["net", "rt-multi-thread"] , workspace = true, default-features = true }
|
||||
tokio = { features = [
|
||||
"net",
|
||||
"rt-multi-thread",
|
||||
], workspace = true, default-features = true }
|
||||
|
||||
[features]
|
||||
wasm = []
|
||||
|
||||
@@ -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<T> = Pin<Box<dyn Stream<Item = T> + 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::<WasmSplitManifest>() {
|
||||
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! {
|
||||
<Link
|
||||
rel="preload"
|
||||
href=format!("{pkg_path}/{module}.wasm")
|
||||
as_="fetch"
|
||||
type_="application/wasm"
|
||||
crossorigin=nonce.clone()
|
||||
/>
|
||||
}
|
||||
.to_html();
|
||||
}
|
||||
_ = view! {
|
||||
<Link rel="modulepreload" href=format!("{pkg_path}/__wasm_split.js") crossorigin=nonce/>
|
||||
}
|
||||
.to_html();
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = Box::pin(
|
||||
meta_context.inject_meta_context(stream).await.then({
|
||||
let sc = Arc::clone(&sc);
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -157,6 +157,14 @@ impl<T: IntoView + 'static, A: Attribute> RenderHtml
|
||||
self.children.hydrate::<FROM_SERVER>(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,
|
||||
|
||||
@@ -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<Chil::State, Fal::State>,
|
||||
>| {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
) -> impl IntoView {
|
||||
static SPLIT_MANIFEST: OnceLock<Option<WasmSplitManifest>> =
|
||||
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! {
|
||||
<link rel="modulepreload" href=format!("{root}/{pkg_path}/{js_file_name}.js") nonce=nonce.clone()/>
|
||||
<link rel="modulepreload" href=format!("{root}/{pkg_path}/{js_file_name}.js") crossorigin=nonce.clone()/>
|
||||
<link
|
||||
rel="preload"
|
||||
href=format!("{root}/{pkg_path}/{wasm_file_name}.wasm")
|
||||
|
||||
@@ -90,6 +90,7 @@ impl<T: RenderHtml> RenderHtml for View<T> {
|
||||
type Owned = View<T::Owned>;
|
||||
|
||||
const MIN_LENGTH: usize = <T as RenderHtml>::MIN_LENGTH;
|
||||
const EXISTS: bool = <T as RenderHtml>::EXISTS;
|
||||
|
||||
async fn resolve(self) -> Self::AsyncOutput {
|
||||
self.inner.resolve().await
|
||||
@@ -107,9 +108,14 @@ impl<T: RenderHtml> RenderHtml for View<T> {
|
||||
mark_branches: bool,
|
||||
extra_attrs: Vec<AnyAttribute>,
|
||||
) {
|
||||
#[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))]
|
||||
let vm = self.view_marker.to_owned();
|
||||
#[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))]
|
||||
#[cfg(debug_assertions)]
|
||||
let vm = if option_env!("LEPTOS_WATCH").is_some() {
|
||||
self.view_marker.to_owned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(vm) = vm.as_ref() {
|
||||
buf.push_str(&format!("<!--hot-reload|{vm}|open-->"));
|
||||
}
|
||||
@@ -122,7 +128,7 @@ impl<T: RenderHtml> RenderHtml for View<T> {
|
||||
extra_attrs,
|
||||
);
|
||||
|
||||
#[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))]
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(vm) = vm.as_ref() {
|
||||
buf.push_str(&format!("<!--hot-reload|{vm}|close-->"));
|
||||
}
|
||||
@@ -138,9 +144,14 @@ impl<T: RenderHtml> RenderHtml for View<T> {
|
||||
) where
|
||||
Self: Sized,
|
||||
{
|
||||
#[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))]
|
||||
let vm = self.view_marker.to_owned();
|
||||
#[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))]
|
||||
#[cfg(debug_assertions)]
|
||||
let vm = if option_env!("LEPTOS_WATCH").is_some() {
|
||||
self.view_marker.to_owned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(vm) = vm.as_ref() {
|
||||
buf.push_sync(&format!("<!--hot-reload|{vm}|open-->"));
|
||||
}
|
||||
@@ -153,7 +164,7 @@ impl<T: RenderHtml> RenderHtml for View<T> {
|
||||
extra_attrs,
|
||||
);
|
||||
|
||||
#[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))]
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(vm) = vm.as_ref() {
|
||||
buf.push_sync(&format!("<!--hot-reload|{vm}|close-->"));
|
||||
}
|
||||
@@ -167,6 +178,14 @@ impl<T: RenderHtml> RenderHtml for View<T> {
|
||||
self.inner.hydrate::<FROM_SERVER>(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(),
|
||||
|
||||
@@ -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,39 @@ 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 send_wrapper;
|
||||
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::<PrefetchLazyFn>() {
|
||||
prefetches.0.write_value().insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WasmSplitManifest(
|
||||
pub reactive_graph::owner::ArcStoredValue<(
|
||||
String,
|
||||
std::collections::HashMap<String, Vec<String>>,
|
||||
)>,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,25 @@ where
|
||||
owner.forget();
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
/// Hydrates the app described by the provided function, starting at `<body>`, with support
|
||||
/// for lazy-loaded routes and components.
|
||||
pub fn hydrate_lazy<F, N>(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<bool> = 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<F, N>(
|
||||
parent: HtmlElement,
|
||||
f: F,
|
||||
) -> UnmountHandle<N::State>
|
||||
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 `<body>`.
|
||||
pub fn mount_to_body<F, N>(f: F)
|
||||
where
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use tachys::prelude::IntoAttributeValue;
|
||||
|
||||
/// Describes a value that is either a static or a reactive string, i.e.,
|
||||
/// a [`String`], a [`&str`], or a reactive `Fn() -> String`.
|
||||
/// a [`String`], a [`&str`], a `Signal` or a reactive `Fn() -> String`.
|
||||
#[derive(Clone)]
|
||||
pub struct TextProp(Arc<dyn Fn() -> Oco<'static, str> + Send + Sync>);
|
||||
|
||||
@@ -82,3 +82,93 @@ impl IntoAttributeValue for TextProp {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! textprop_reactive {
|
||||
($name:ident, <$($gen:ident),*>, $v:ty, $( $where_clause:tt )*) =>
|
||||
{
|
||||
#[allow(deprecated)]
|
||||
impl<$($gen),*> From<$name<$($gen),*>> for TextProp
|
||||
where
|
||||
$v: Into<Oco<'static, str>> + Clone + Send + Sync + 'static,
|
||||
$($where_clause)*
|
||||
{
|
||||
#[inline(always)]
|
||||
fn from(s: $name<$($gen),*>) -> Self {
|
||||
TextProp(Arc::new(move || s.get().into()))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
mod stable {
|
||||
use super::TextProp;
|
||||
use oco_ref::Oco;
|
||||
#[allow(deprecated)]
|
||||
use reactive_graph::wrappers::read::MaybeSignal;
|
||||
use reactive_graph::{
|
||||
computed::{ArcMemo, Memo},
|
||||
owner::Storage,
|
||||
signal::{ArcReadSignal, ArcRwSignal, ReadSignal, RwSignal},
|
||||
traits::Get,
|
||||
wrappers::read::{ArcSignal, Signal},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
textprop_reactive!(
|
||||
RwSignal,
|
||||
<V, S>,
|
||||
V,
|
||||
RwSignal<V, S>: Get<Value = V>,
|
||||
S: Storage<V> + Storage<Option<V>>,
|
||||
S: Send + Sync + 'static,
|
||||
);
|
||||
textprop_reactive!(
|
||||
ReadSignal,
|
||||
<V, S>,
|
||||
V,
|
||||
ReadSignal<V, S>: Get<Value = V>,
|
||||
S: Storage<V> + Storage<Option<V>>,
|
||||
S: Send + Sync + 'static,
|
||||
);
|
||||
textprop_reactive!(
|
||||
Memo,
|
||||
<V, S>,
|
||||
V,
|
||||
Memo<V, S>: Get<Value = V>,
|
||||
S: Storage<V> + Storage<Option<V>>,
|
||||
S: Send + Sync + 'static,
|
||||
);
|
||||
textprop_reactive!(
|
||||
Signal,
|
||||
<V, S>,
|
||||
V,
|
||||
Signal<V, S>: Get<Value = V>,
|
||||
S: Storage<V> + Storage<Option<V>>,
|
||||
S: Send + Sync + 'static,
|
||||
);
|
||||
textprop_reactive!(
|
||||
MaybeSignal,
|
||||
<V, S>,
|
||||
V,
|
||||
MaybeSignal<V, S>: Get<Value = V>,
|
||||
S: Storage<V> + Storage<Option<V>>,
|
||||
S: Send + Sync + 'static,
|
||||
);
|
||||
textprop_reactive!(ArcRwSignal, <V>, V, ArcRwSignal<V>: Get<Value = V>);
|
||||
textprop_reactive!(ArcReadSignal, <V>, V, ArcReadSignal<V>: Get<Value = V>);
|
||||
textprop_reactive!(ArcMemo, <V>, V, ArcMemo<V>: Get<Value = V>);
|
||||
textprop_reactive!(ArcSignal, <V>, V, ArcSignal<V>: Get<Value = V>);
|
||||
}
|
||||
|
||||
/// Extension trait for `Option<TextProp>`
|
||||
pub trait OptionTextPropExt {
|
||||
/// Accesses the current value of the `Option<TextProp>` as an `Option<Oco<'static, str>>`.
|
||||
fn get(&self) -> Option<Oco<'static, str>>;
|
||||
}
|
||||
|
||||
impl OptionTextPropExt for Option<TextProp> {
|
||||
fn get(&self) -> Option<Oco<'static, str>> {
|
||||
self.as_ref().map(|text_prop| text_prop.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,19 @@ macro_rules! error {
|
||||
($($t:tt)*) => ($crate::logging::console_error(&format_args!($($t)*).to_string()))
|
||||
}
|
||||
|
||||
/// Uses `println!()`-style formatting to log something to the console (in the browser)
|
||||
/// or via `println!()` (if not in the browser), but only if it's a debug build.
|
||||
#[macro_export]
|
||||
macro_rules! debug_log {
|
||||
($($x:tt)*) => {
|
||||
{
|
||||
if cfg!(debug_assertions) {
|
||||
$crate::log!($($x)*)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses `println!()`-style formatting to log warnings to the console (in the browser)
|
||||
/// or via `eprintln!()` (if not in the browser), but only if it's a debug build.
|
||||
#[macro_export]
|
||||
@@ -36,6 +49,19 @@ macro_rules! debug_warn {
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses `println!()`-style formatting to log errors to the console (in the browser)
|
||||
/// or via `eprintln!()` (if not in the browser), but only if it's a debug build.
|
||||
#[macro_export]
|
||||
macro_rules! debug_error {
|
||||
($($x:tt)*) => {
|
||||
{
|
||||
if cfg!(debug_assertions) {
|
||||
$crate::error!($($x)*)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn log_to_stdout() -> bool {
|
||||
cfg!(not(all(
|
||||
target_arch = "wasm32",
|
||||
@@ -55,7 +81,7 @@ pub fn console_log(s: &str) {
|
||||
}
|
||||
|
||||
/// Log a warning to the console (in the browser)
|
||||
/// or via `println!()` (if not in the browser).
|
||||
/// or via `eprintln!()` (if not in the browser).
|
||||
pub fn console_warn(s: &str) {
|
||||
if log_to_stdout() {
|
||||
eprintln!("{s}");
|
||||
@@ -65,7 +91,7 @@ pub fn console_warn(s: &str) {
|
||||
}
|
||||
|
||||
/// Log an error to the console (in the browser)
|
||||
/// or via `println!()` (if not in the browser).
|
||||
/// or via `eprintln!()` (if not in the browser).
|
||||
#[inline(always)]
|
||||
pub fn console_error(s: &str) {
|
||||
if log_to_stdout() {
|
||||
@@ -75,21 +101,29 @@ pub fn console_error(s: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an error to the console (in the browser)
|
||||
/// Log a string to the console (in the browser)
|
||||
/// or via `println!()` (if not in the browser), but only in a debug build.
|
||||
#[inline(always)]
|
||||
pub fn console_debug_warn(s: &str) {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if log_to_stdout() {
|
||||
eprintln!("{s}");
|
||||
} else {
|
||||
web_sys::console::warn_1(&JsValue::from_str(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = s;
|
||||
pub fn console_debug_log(s: &str) {
|
||||
if cfg!(debug_assertions) {
|
||||
console_log(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning to the console (in the browser)
|
||||
/// or via `eprintln!()` (if not in the browser), but only in a debug build.
|
||||
#[inline(always)]
|
||||
pub fn console_debug_warn(s: &str) {
|
||||
if cfg!(debug_assertions) {
|
||||
console_warn(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an error to the console (in the browser)
|
||||
/// or via `eprintln!()` (if not in the browser), but only in a debug build.
|
||||
#[inline(always)]
|
||||
pub fn console_debug_error(s: &str) {
|
||||
if cfg!(debug_assertions) {
|
||||
console_error(s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,93 +251,67 @@ impl LNode {
|
||||
action: PatchAction::ClearChildren,
|
||||
}]
|
||||
} else {
|
||||
let mut a = 0;
|
||||
let mut b = std::cmp::max(old.len(), new.len()) - 1; // min is 0, have checked both have items
|
||||
let width = old.len() + 1;
|
||||
let height = new.len() + 1;
|
||||
let mut mat = vec![0; width * height];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 1..width {
|
||||
mat[i] = i;
|
||||
}
|
||||
for i in 1..height {
|
||||
mat[i * width] = i;
|
||||
}
|
||||
for j in 1..height {
|
||||
for i in 1..width {
|
||||
if old[i - 1] == new[j - 1] {
|
||||
mat[j * width + i] = mat[(j - 1) * width + (i - 1)];
|
||||
} else {
|
||||
mat[j * width + i] = (mat[(j - 1) * width + i] + 1)
|
||||
.min(mat[j * width + (i - 1)] + 1)
|
||||
.min(mat[(j - 1) * width + (i - 1)] + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut i, mut j) = (old.len(), new.len());
|
||||
let mut patches = vec![];
|
||||
// common prefix
|
||||
while a < b {
|
||||
let old = old.get(a);
|
||||
let new = new.get(a);
|
||||
|
||||
match (old, new) {
|
||||
(None, Some(new)) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::InsertChild {
|
||||
before: a,
|
||||
child: new.to_replacement_node(old_children),
|
||||
},
|
||||
}),
|
||||
(Some(_), None) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::RemoveChild { at: a },
|
||||
}),
|
||||
(Some(old), Some(new)) if old != new => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
a += 1;
|
||||
}
|
||||
|
||||
// common suffix
|
||||
while b >= a {
|
||||
let old = old.get(b);
|
||||
let new = new.get(b);
|
||||
|
||||
match (old, new) {
|
||||
(None, Some(new)) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::InsertChildAfter {
|
||||
after: b - 1,
|
||||
child: new.to_replacement_node(old_children),
|
||||
},
|
||||
}),
|
||||
(Some(_), None) => patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::RemoveChild { at: b },
|
||||
}),
|
||||
(Some(old), Some(new)) if old != new => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if b == 0 {
|
||||
break;
|
||||
}
|
||||
b -= 1;
|
||||
}
|
||||
|
||||
// diffing in middle
|
||||
if b >= a {
|
||||
let old_slice_end =
|
||||
if b >= old.len() { old.len() - 1 } else { b };
|
||||
let new_slice_end =
|
||||
if b >= new.len() { new.len() - 1 } else { b };
|
||||
let old = &old[a..=old_slice_end];
|
||||
let new = &new[a..=new_slice_end];
|
||||
|
||||
for (new_idx, new_node) in new.iter().enumerate() {
|
||||
match old.get(new_idx) {
|
||||
Some(old_node) => {
|
||||
let mut new_path = path.to_vec();
|
||||
new_path.push(new_idx + a);
|
||||
let diffs = old_node.diff_at(
|
||||
new_node,
|
||||
&new_path,
|
||||
old_children,
|
||||
);
|
||||
patches.extend(&mut diffs.into_iter());
|
||||
}
|
||||
None => patches.push(Patch {
|
||||
while i > 0 || j > 0 {
|
||||
if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else {
|
||||
let current = mat[j * width + i];
|
||||
if i > 0
|
||||
&& j > 0
|
||||
&& mat[(j - 1) * width + i - 1] + 1 == current
|
||||
{
|
||||
let mut new_path = path.to_owned();
|
||||
new_path.push(i - 1);
|
||||
let diffs = old[i - 1].diff_at(
|
||||
&new[j - 1],
|
||||
&new_path,
|
||||
old_children,
|
||||
);
|
||||
patches.extend(&mut diffs.into_iter());
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if i > 0 && mat[j * width + i - 1] + 1 == current {
|
||||
patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::RemoveChild { at: i - 1 },
|
||||
});
|
||||
i -= 1;
|
||||
} else if j > 0 && mat[(j - 1) * width + i] + 1 == current {
|
||||
patches.push(Patch {
|
||||
path: path.to_owned(),
|
||||
action: PatchAction::InsertChild {
|
||||
before: new_idx,
|
||||
child: new_node
|
||||
before: i,
|
||||
child: new[j - 1]
|
||||
.to_replacement_node(old_children),
|
||||
},
|
||||
}),
|
||||
});
|
||||
j -= 1;
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,23 +488,17 @@ mod tests {
|
||||
let delta = a.diff(&b);
|
||||
assert_eq!(
|
||||
delta,
|
||||
vec![
|
||||
Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::InsertChildAfter {
|
||||
after: 0,
|
||||
child: ReplacementNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![ReplacementNode::Html("bar".into())]
|
||||
}
|
||||
vec![Patch {
|
||||
path: vec![],
|
||||
action: PatchAction::InsertChild {
|
||||
before: 0,
|
||||
child: ReplacementNode::Element {
|
||||
name: "button".into(),
|
||||
attrs: vec![],
|
||||
children: vec![ReplacementNode::Html("foo".into())]
|
||||
}
|
||||
},
|
||||
Patch {
|
||||
path: vec![0, 0],
|
||||
action: PatchAction::SetText("foo".into())
|
||||
}
|
||||
]
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,10 @@ impl ViewMacros {
|
||||
}
|
||||
diffs
|
||||
} else {
|
||||
// TODO: instead of simply returning no patches, when number of views differs,
|
||||
// we can compare views content to determine which views were shifted
|
||||
// or come up with another idea that will allow to send patches when views were shifted/removed/added
|
||||
lock.insert(path.clone(), new_views);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ impl LNode {
|
||||
LNode::parse_node(child, views)?;
|
||||
}
|
||||
}
|
||||
Node::RawText(text) => {
|
||||
views.push(LNode::Text(text.to_string_best()));
|
||||
}
|
||||
Node::Text(text) => {
|
||||
views.push(LNode::Text(text.value_string()));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
console.log("[HOT RELOADING] Connected to server.\n\nNote: `cargo-leptos watch --hot-reload` only works with the `nightly` feature enabled on Leptos.");
|
||||
console.log("[HOT RELOADING] Connected to server.");
|
||||
function patch(json) {
|
||||
try {
|
||||
const views = JSON.parse(json);
|
||||
for (const [id, patches] of views) {
|
||||
console.log("[HOT RELOAD]", id, patches);
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT),
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_COMMENT,
|
||||
),
|
||||
open = `hot-reload|${id}|open`,
|
||||
close = `hot-reload|${id}|close`;
|
||||
let start, end;
|
||||
@@ -21,150 +24,200 @@ function patch(json) {
|
||||
}
|
||||
|
||||
for (const [start, end] of instances) {
|
||||
// build tree of current actual children
|
||||
const actualChildren = childrenFromRange(start.parentElement, start, end);
|
||||
const actions = [];
|
||||
|
||||
// build up the set of actions
|
||||
for (const patch of patches) {
|
||||
const actualChildren = childrenFromRange(
|
||||
start.parentElement,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const child = childAtPath(
|
||||
actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0],
|
||||
patch.path
|
||||
actualChildren.length > 1
|
||||
? { children: actualChildren }
|
||||
: actualChildren[0],
|
||||
patch.path,
|
||||
);
|
||||
const action = patch.action;
|
||||
if (action == "ClearChildren") {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > ClearChildren", child.node);
|
||||
console.log("[HOT RELOAD] > ClearChildren", child.node);
|
||||
if (child.node) {
|
||||
child.node.textContent = "";
|
||||
});
|
||||
} else if (action.ReplaceWith) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > ReplaceWith", child, action.ReplaceWith);
|
||||
const replacement = fromReplacementNode(action.ReplaceWith, actualChildren);
|
||||
if (child.node) {
|
||||
child.node.replaceWith(replacement);
|
||||
} else {
|
||||
const range = new Range();
|
||||
range.setStartAfter(child.start);
|
||||
range.setEndAfter(child.end);
|
||||
range.deleteContents();
|
||||
child.start.replaceWith(replacement);
|
||||
} else {
|
||||
for (const existingChild of child.children) {
|
||||
let parent = existingChild.node.parentElement;
|
||||
parent.removeChild(existingChild.node);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (action.ReplaceWith) {
|
||||
console.log(
|
||||
"[HOT RELOAD] > ReplaceWith",
|
||||
child,
|
||||
action.ReplaceWith,
|
||||
);
|
||||
const replacement = fromReplacementNode(
|
||||
action.ReplaceWith,
|
||||
actualChildren,
|
||||
);
|
||||
if (child.node) {
|
||||
child.node.replaceWith(replacement);
|
||||
} else {
|
||||
if (child.children) {
|
||||
child.children[0].node.parentElement.insertBefore(
|
||||
replacement,
|
||||
child.children[0].node,
|
||||
);
|
||||
for (const existingChild of child.children) {
|
||||
existingChild.node.parentElement.removeChild(
|
||||
existingChild.node,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action.ChangeTagName) {
|
||||
const oldNode = child.node;
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > ChangeTagName", child.node, action.ChangeTagName);
|
||||
const newElement = document.createElement(action.ChangeTagName);
|
||||
for (const attr of oldNode.attributes) {
|
||||
newElement.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
for (const childNode of child.node.childNodes) {
|
||||
newElement.appendChild(childNode);
|
||||
}
|
||||
console.log(
|
||||
"[HOT RELOAD] > ChangeTagName",
|
||||
child.node,
|
||||
action.ChangeTagName,
|
||||
);
|
||||
const newElement = document.createElement(action.ChangeTagName);
|
||||
for (const attr of oldNode.attributes) {
|
||||
newElement.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
for (const childNode of child.node.childNodes) {
|
||||
newElement.appendChild(childNode);
|
||||
}
|
||||
|
||||
child.node.replaceWith(newElement);
|
||||
});
|
||||
child.node.replaceWith(newElement);
|
||||
} else if (action.RemoveAttribute) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > RemoveAttribute", child.node, action.RemoveAttribute);
|
||||
child.node.removeAttribute(action.RemoveAttribute);
|
||||
});
|
||||
console.log(
|
||||
"[HOT RELOAD] > RemoveAttribute",
|
||||
child.node,
|
||||
action.RemoveAttribute,
|
||||
);
|
||||
child.node.removeAttribute(action.RemoveAttribute);
|
||||
} else if (action.SetAttribute) {
|
||||
const [name, value] = action.SetAttribute;
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > SetAttribute", child.node, action.SetAttribute);
|
||||
child.node.setAttribute(name, value);
|
||||
});
|
||||
console.log(
|
||||
"[HOT RELOAD] > SetAttribute",
|
||||
child.node,
|
||||
action.SetAttribute,
|
||||
);
|
||||
child.node.setAttribute(name, value);
|
||||
} else if (action.SetText) {
|
||||
const node = child.node;
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > SetText", child.node, action.SetText);
|
||||
node.textContent = action.SetText;
|
||||
});
|
||||
console.log("[HOT RELOAD] > SetText", child.node, action.SetText);
|
||||
node.textContent = action.SetText;
|
||||
} else if (action.AppendChildren) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > AppendChildren", child.node, action.AppendChildren);
|
||||
const newChildren = fromReplacementNode(action.AppendChildren, actualChildren);
|
||||
child.node.append(newChildren);
|
||||
});
|
||||
console.log(
|
||||
"[HOT RELOAD] > AppendChildren",
|
||||
child.node,
|
||||
action.AppendChildren,
|
||||
);
|
||||
const newChildren = action.AppendChildren.map((x) =>
|
||||
fromReplacementNode(x, actualChildren),
|
||||
);
|
||||
child.node.append(...newChildren);
|
||||
} else if (action.RemoveChild) {
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > RemoveChild", child.node, child.children, action.RemoveChild);
|
||||
const toRemove = child.children[action.RemoveChild.at];
|
||||
let toRemoveNode = toRemove.node;
|
||||
if (!toRemoveNode) {
|
||||
const range = new Range();
|
||||
range.setStartBefore(toRemove.start);
|
||||
range.setEndAfter(toRemove.end);
|
||||
toRemoveNode = range.deleteContents();
|
||||
} else {
|
||||
toRemoveNode.parentNode.removeChild(toRemoveNode);
|
||||
}
|
||||
});
|
||||
console.log(
|
||||
"[HOT RELOAD] > RemoveChild",
|
||||
child.node,
|
||||
child.children,
|
||||
action.RemoveChild,
|
||||
);
|
||||
const toRemove = child.children[action.RemoveChild.at];
|
||||
let toRemoveNode = toRemove.node;
|
||||
if (!toRemoveNode) {
|
||||
const range = new Range();
|
||||
range.setStartBefore(toRemove.start);
|
||||
range.setEndAfter(toRemove.end);
|
||||
toRemoveNode = range.deleteContents();
|
||||
} else {
|
||||
toRemoveNode.parentNode.removeChild(toRemoveNode);
|
||||
}
|
||||
} else if (action.InsertChild) {
|
||||
const newChild = fromReplacementNode(action.InsertChild.child, actualChildren);
|
||||
const newChild = fromReplacementNode(
|
||||
action.InsertChild.child,
|
||||
actualChildren,
|
||||
);
|
||||
let children = [];
|
||||
if (child.children) {
|
||||
children = child.children;
|
||||
} else if (child.start && child.end) {
|
||||
children = childrenFromRange(child.node || child.start.parentElement, start, end);
|
||||
children = childrenFromRange(
|
||||
child.node || child.start.parentElement,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
} else {
|
||||
console.warn("InsertChildAfter could not build children.");
|
||||
}
|
||||
const before = children[action.InsertChild.before];
|
||||
actions.push(() => {
|
||||
console.log("[HOT RELOAD] > InsertChild", child, child.node, action.InsertChild, " before ", before);
|
||||
if (!before && child.node) {
|
||||
child.node.appendChild(newChild);
|
||||
} else {
|
||||
let node = child.node || child.end.parentElement;
|
||||
const reference = before ? before.node || before.start : child.end;
|
||||
node.insertBefore(newChild, reference);
|
||||
}
|
||||
});
|
||||
const beforeNode = children[action.InsertChild.before];
|
||||
console.log(
|
||||
"[HOT RELOAD] > InsertChild",
|
||||
child,
|
||||
child.node,
|
||||
action.InsertChild,
|
||||
" before ",
|
||||
beforeNode,
|
||||
);
|
||||
if (beforeNode) {
|
||||
let node = beforeNode.node || beforeNode.start.previousSibling;
|
||||
node.parentElement.insertBefore(newChild, node);
|
||||
} else if (child.node) {
|
||||
child.node.appendChild(newChild);
|
||||
} else if (children) {
|
||||
let lastNode = children[children.length - 1];
|
||||
let afterNode = lastNode.node || lastNode.end.nextSibling;
|
||||
afterNode.after(newChild);
|
||||
}
|
||||
} else if (action.InsertChildAfter) {
|
||||
const newChild = fromReplacementNode(action.InsertChildAfter.child, actualChildren);
|
||||
const newChild = fromReplacementNode(
|
||||
action.InsertChildAfter.child,
|
||||
actualChildren,
|
||||
);
|
||||
let children = [];
|
||||
if (child.children) {
|
||||
children = child.children;
|
||||
} else if (child.start && child.end) {
|
||||
children = childrenFromRange(child.node || child.start.parentElement, start, end);
|
||||
children = childrenFromRange(
|
||||
child.node || child.start.parentElement,
|
||||
start,
|
||||
end,
|
||||
);
|
||||
} else {
|
||||
console.warn("InsertChildAfter could not build children.");
|
||||
}
|
||||
const after = children[action.InsertChildAfter.after];
|
||||
actions.push(() => {
|
||||
console.log(
|
||||
"[HOT RELOAD] > InsertChildAfter",
|
||||
child,
|
||||
child.node,
|
||||
action.InsertChildAfter,
|
||||
" after ",
|
||||
after
|
||||
);
|
||||
if (child.node && (!after || !(after.node || after.start).nextSibling)) {
|
||||
child.node.appendChild(newChild);
|
||||
console.log(
|
||||
"[HOT RELOAD] > InsertChildAfter",
|
||||
child,
|
||||
child.node,
|
||||
action.InsertChildAfter,
|
||||
" after ",
|
||||
after,
|
||||
);
|
||||
if (
|
||||
child.node &&
|
||||
(!after || !(after.node || after.start).nextSibling)
|
||||
) {
|
||||
child.node.appendChild(newChild);
|
||||
} else {
|
||||
const node = child.node || child.end;
|
||||
const parent =
|
||||
node.nodeType === Node.COMMENT_NODE ? node.parentNode : node;
|
||||
if (!after) {
|
||||
parent.appendChild(newChild);
|
||||
} else {
|
||||
const node = child.node || child.end;
|
||||
const parent = node.nodeType === Node.COMMENT_NODE ? node.parentNode : node;
|
||||
if (!after) {
|
||||
parent.appendChild(newChild);
|
||||
} else {
|
||||
parent.insertBefore(newChild, (after.node || after.start).nextSibling);
|
||||
}
|
||||
parent.insertBefore(
|
||||
newChild,
|
||||
(after.node || after.start).nextSibling,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Unmatched action", action);
|
||||
}
|
||||
}
|
||||
|
||||
// actually run the actions
|
||||
// the reason we delay them is so that children aren't moved before other children are found, etc.
|
||||
for (const action of actions) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -191,8 +244,10 @@ function patch(json) {
|
||||
return element;
|
||||
} else {
|
||||
const child = childAtPath(
|
||||
actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0],
|
||||
node.Path
|
||||
actualChildren.length > 1
|
||||
? { children: actualChildren }
|
||||
: actualChildren[0],
|
||||
node.Path,
|
||||
);
|
||||
if (child) {
|
||||
let childNode = child.node;
|
||||
@@ -215,7 +270,10 @@ function patch(json) {
|
||||
}
|
||||
return childNode;
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Could not find replacement node at ", node.Path);
|
||||
console.warn(
|
||||
"[HOT RELOADING] Could not find replacement node at ",
|
||||
node.Path,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -227,13 +285,16 @@ function patch(json) {
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
if (node.parentNode == element && (!range || range.isPointInRange(node, 0))) {
|
||||
if (
|
||||
node.parentNode == element &&
|
||||
(!range || range.isPointInRange(node, 0))
|
||||
) {
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
} else {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
const actualChildren = [],
|
||||
elementCount = {};
|
||||
@@ -259,18 +320,22 @@ function patch(json) {
|
||||
node: walker.currentNode,
|
||||
});
|
||||
} else if (walker.currentNode.nodeType == Node.COMMENT_NODE) {
|
||||
if (walker.currentNode.textContent.trim().startsWith("hot-reload")) {
|
||||
if (walker.currentNode.textContent.trim().endsWith("-children|open")) {
|
||||
if (walker.currentNode.textContent.trim().startsWith("hot-reload|")) {
|
||||
if (walker.currentNode.textContent.trim().endsWith("|open")) {
|
||||
const startingName = walker.currentNode.textContent.trim();
|
||||
const componentName = startingName.replace("-children|open").replace("hot-reload|");
|
||||
const endingName = `hot-reload|${componentName}-children|close`;
|
||||
const componentName = startingName
|
||||
.replace("|open", "")
|
||||
.replace("hot-reload|", "");
|
||||
const endingName = `hot-reload|${componentName}|close`;
|
||||
let start = walker.currentNode;
|
||||
let depth = 1;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent.trim() == endingName) {
|
||||
depth--;
|
||||
} else if (walker.currentNode.textContent.trim() == startingName) {
|
||||
} else if (
|
||||
walker.currentNode.textContent.trim() == startingName
|
||||
) {
|
||||
depth++;
|
||||
}
|
||||
|
||||
@@ -283,7 +348,11 @@ function patch(json) {
|
||||
type: "fragment",
|
||||
start: start.nextSibling,
|
||||
end: end.previousSibling,
|
||||
children: childrenFromRange(start.parentElement, start.nextSibling, end.previousSibling),
|
||||
children: childrenFromRange(
|
||||
start.parentElement,
|
||||
start.nextSibling,
|
||||
end.previousSibling,
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (walker.currentNode.textContent.trim() == "<() />") {
|
||||
@@ -358,7 +427,10 @@ function patch(json) {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Building children, encountered", walker.currentNode);
|
||||
console.warn(
|
||||
"[HOT RELOADING] Building children, encountered",
|
||||
walker.currentNode,
|
||||
);
|
||||
}
|
||||
}
|
||||
return actualChildren;
|
||||
@@ -374,7 +446,11 @@ function patch(json) {
|
||||
} else if (path == [0]) {
|
||||
return element;
|
||||
} else if (element.start && element.end) {
|
||||
const actualChildren = childrenFromRange(element.node || element.start.parentElement, element.start, element.end);
|
||||
const actualChildren = childrenFromRange(
|
||||
element.node || element.start.parentElement,
|
||||
element.start,
|
||||
element.end,
|
||||
);
|
||||
return childAtPath({ children: actualChildren }, path);
|
||||
} else {
|
||||
console.warn("[HOT RELOADING] Child at ", path, "not found in ", element);
|
||||
|
||||
@@ -19,6 +19,7 @@ use syn::{
|
||||
|
||||
pub struct Model {
|
||||
is_transparent: bool,
|
||||
is_lazy: bool,
|
||||
island: Option<String>,
|
||||
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::<true>(&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::<true>(&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<String>) -> Self {
|
||||
self.island = island;
|
||||
|
||||
@@ -3,30 +3,75 @@ 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::<ItemFn>(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::<ItemFn>(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,
|
||||
::leptos::__reexports::send_wrapper
|
||||
)]
|
||||
#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()
|
||||
}
|
||||
|
||||
@@ -358,16 +358,14 @@ fn view_macro_impl(tokens: TokenStream, template: bool) -> TokenStream {
|
||||
}
|
||||
|
||||
fn normalized_call_site(site: proc_macro::Span) -> Option<String> {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(all(debug_assertions, feature = "nightly", rustc_nightly))] {
|
||||
Some(leptos_hot_reload::span_to_stable_id(
|
||||
site.file(),
|
||||
site.start().line()
|
||||
))
|
||||
} else {
|
||||
_ = site;
|
||||
None
|
||||
}
|
||||
if cfg!(debug_assertions) {
|
||||
Some(leptos_hot_reload::span_to_stable_id(
|
||||
site.file(),
|
||||
site.start().line(),
|
||||
))
|
||||
} else {
|
||||
_ = site;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,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
|
||||
@@ -655,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<String>,
|
||||
) -> TokenStream {
|
||||
let mut dummy = syn::parse::<DummyModel>(s.clone());
|
||||
let parse_result = syn::parse::<component::Model>(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(),
|
||||
@@ -692,6 +691,7 @@ fn component_macro(
|
||||
}
|
||||
unexpanded.sig.ident =
|
||||
unmodified_fn_name_from_fn_name(&unexpanded.sig.ident);
|
||||
|
||||
quote! {
|
||||
#expanded
|
||||
|
||||
@@ -1030,14 +1030,41 @@ pub fn memo(input: TokenStream) -> TokenStream {
|
||||
memo::memo_impl(input)
|
||||
}
|
||||
|
||||
/// The `#[lazy]` macro marks an `async` function as a function that can be lazy-loaded from a
|
||||
/// separate (WebAssembly) binary.
|
||||
/// The `#[lazy]` macro indicates that a function can be lazy-loaded from a separate WebAssembly (WASM) binary.
|
||||
///
|
||||
/// The first time the function is called, calling the function will first load that other binary,
|
||||
/// then call the function. On subsequent call it will be called immediately, but still return
|
||||
/// then call the function. On subsequent calls it will be called immediately, but still return
|
||||
/// asynchronously to maintain the same API.
|
||||
///
|
||||
/// All parameters and output types should be concrete types, with no generics.
|
||||
/// `#[lazy]` can be used to annotate synchronous or `async` functions. In both cases, the final function will be
|
||||
/// `async` and must be called as such.
|
||||
///
|
||||
/// All parameters and output types should be concrete types, with no generics or `impl Trait` types.
|
||||
///
|
||||
/// This should be used in tandem with a suitable build process, such as `cargo leptos --split`.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_macro::lazy;
|
||||
///
|
||||
/// #[lazy]
|
||||
/// fn lazy_synchronous_function() -> String {
|
||||
/// "Hello, lazy world!".to_string()
|
||||
/// }
|
||||
///
|
||||
/// #[lazy]
|
||||
/// async fn lazy_async_function() -> String {
|
||||
/// /* do something that requires async work */
|
||||
/// "Hello, lazy async world!".to_string()
|
||||
/// }
|
||||
///
|
||||
/// async fn use_lazy_functions() {
|
||||
/// // synchronous function has been converted to async
|
||||
/// let value1 = lazy_synchronous_function().await;
|
||||
///
|
||||
/// // async function is still async
|
||||
/// let value1 = lazy_async_function().await;
|
||||
/// }
|
||||
/// ```
|
||||
#[proc_macro_attribute]
|
||||
#[proc_macro_error]
|
||||
pub fn lazy(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
|
||||
|
||||
@@ -44,6 +44,8 @@ pub fn render_view(
|
||||
view_marker: Option<String>,
|
||||
disable_inert_html: bool,
|
||||
) -> Option<TokenStream> {
|
||||
let disable_inert_html = disable_inert_html || global_class.is_some();
|
||||
|
||||
let (base, should_add_view) = match nodes.len() {
|
||||
0 => {
|
||||
let span = Span::call_site();
|
||||
@@ -401,6 +403,9 @@ fn inert_element_to_tokens(
|
||||
}
|
||||
}
|
||||
|
||||
/// # Note
|
||||
/// Should not be used on top level `<svg>` elements.
|
||||
/// Use [`inert_element_to_tokens`] instead.
|
||||
fn inert_svg_element_to_tokens(
|
||||
node: &Node<impl CustomNode>,
|
||||
escape_text: bool,
|
||||
@@ -704,7 +709,7 @@ fn node_to_tokens(
|
||||
&& el_name != "textarea";
|
||||
|
||||
let el_name = el_node.name().to_string();
|
||||
if is_svg_element(&el_name) {
|
||||
if is_svg_element(&el_name) && el_name != "svg" {
|
||||
Some(inert_svg_element_to_tokens(
|
||||
node,
|
||||
escape,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_meta"
|
||||
version = "0.8.2"
|
||||
version = { workspace = true }
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
@@ -13,8 +13,8 @@ leptos = { workspace = true }
|
||||
or_poisoned = { workspace = true }
|
||||
indexmap = { workspace = true, default-features = true }
|
||||
send_wrapper = { workspace = true, default-features = true }
|
||||
tracing = { optional = true , workspace = true, default-features = true }
|
||||
wasm-bindgen = { workspace = true , default-features = true }
|
||||
tracing = { optional = true, workspace = true, default-features = true }
|
||||
wasm-bindgen = { workspace = true, default-features = true }
|
||||
futures = { workspace = true, default-features = true }
|
||||
|
||||
[dependencies.web-sys]
|
||||
|
||||
@@ -413,6 +413,7 @@ where
|
||||
type Owned = RegisteredMetaTag<E, At::CloneableOwned, Ch::Owned>;
|
||||
|
||||
const MIN_LENGTH: usize = 0;
|
||||
const EXISTS: bool = false;
|
||||
|
||||
fn dry_resolve(&mut self) {
|
||||
self.el.dry_resolve()
|
||||
|
||||
@@ -322,6 +322,7 @@ impl RenderHtml for TitleView {
|
||||
type Owned = Self;
|
||||
|
||||
const MIN_LENGTH: usize = 0;
|
||||
const EXISTS: bool = false;
|
||||
|
||||
fn dry_resolve(&mut self) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "oco_ref"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
authors = ["Danik Vitek", "Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/leptos-rs/leptos"
|
||||
@@ -10,7 +10,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, default-features = true }
|
||||
thiserror = { workspace = true , default-features = true }
|
||||
thiserror = { workspace = true, default-features = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true , default-features = true }
|
||||
serde_json = { workspace = true, default-features = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reactive_graph"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
readme = "../README.md"
|
||||
@@ -16,19 +16,26 @@ futures = { workspace = true, default-features = true }
|
||||
hydration_context = { workspace = true, optional = true }
|
||||
pin-project-lite = { workspace = true, default-features = true }
|
||||
rustc-hash = { workspace = true, default-features = true }
|
||||
serde = { features = ["derive"], optional = true , workspace = true, default-features = true }
|
||||
serde = { features = [
|
||||
"derive",
|
||||
], optional = true, workspace = true, default-features = true }
|
||||
slotmap = { workspace = true, default-features = true }
|
||||
thiserror = { workspace = true , default-features = true }
|
||||
tracing = { optional = true , workspace = true, default-features = true }
|
||||
thiserror = { workspace = true, default-features = true }
|
||||
tracing = { optional = true, workspace = true, default-features = true }
|
||||
guardian = { workspace = true, default-features = true }
|
||||
async-lock = { workspace = true, default-features = true }
|
||||
send_wrapper = { features = ["futures"] , workspace = true, default-features = true }
|
||||
send_wrapper = { features = [
|
||||
"futures",
|
||||
], workspace = true, default-features = true }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
web-sys = { version = "0.3.77", features = ["console"] }
|
||||
|
||||
[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"] }
|
||||
|
||||
|
||||
@@ -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>) -> T + 'static,
|
||||
value: impl IntoFuture<Output = T> + '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<dyn FnMut(Option<T>) -> T + 'static>,
|
||||
initial_value: Option<T>,
|
||||
@@ -127,6 +141,73 @@ where
|
||||
RenderEffect { value, inner }
|
||||
}
|
||||
|
||||
async fn new_with_async_value_erased(
|
||||
mut fun: Box<dyn FnMut(Option<T>) -> T + 'static>,
|
||||
initial_value: Pin<Box<dyn Future<Output = T>>>,
|
||||
) -> Self {
|
||||
// codegen optimisation:
|
||||
fn prep() -> (Owner, Arc<RwLock<EffectInner>>, 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::<T>));
|
||||
|
||||
#[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<U>(
|
||||
&self,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reactive_stores"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
readme = "../README.md"
|
||||
@@ -11,7 +11,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
guardian = { workspace = true, default-features = true }
|
||||
itertools = { workspace = true , default-features = true }
|
||||
itertools = { workspace = true, default-features = true }
|
||||
or_poisoned = { workspace = true }
|
||||
paste = { workspace = true, default-features = true }
|
||||
reactive_graph = { workspace = true }
|
||||
@@ -21,7 +21,10 @@ dashmap = { workspace = true, default-features = true }
|
||||
send_wrapper = { workspace = true, default-features = true }
|
||||
|
||||
[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"] }
|
||||
reactive_graph = { workspace = true, features = ["effects"] }
|
||||
|
||||
@@ -1105,11 +1105,6 @@ mod tests {
|
||||
assert_eq!(combined_count.load(Ordering::Relaxed), 3);
|
||||
}
|
||||
|
||||
#[derive(Debug, Store)]
|
||||
pub struct StructWithOption {
|
||||
opt_field: Option<Todo>,
|
||||
}
|
||||
|
||||
// regression test for https://github.com/leptos-rs/leptos/issues/3523
|
||||
#[tokio::test]
|
||||
async fn notifying_all_descendants() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reactive_stores_macro"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
readme = "../README.md"
|
||||
@@ -13,8 +13,8 @@ edition.workspace = true
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
convert_case = { workspace = true , default-features = true }
|
||||
convert_case = { workspace = true, default-features = true }
|
||||
proc-macro-error2 = { workspace = true, default-features = true }
|
||||
proc-macro2 = { workspace = true, default-features = true }
|
||||
quote = { workspace = true, default-features = true }
|
||||
syn = { features = ["full"] , workspace = true, default-features = true }
|
||||
syn = { features = ["full"], workspace = true, default-features = true }
|
||||
|
||||
@@ -111,10 +111,8 @@ impl ToTokens for Model {
|
||||
} = &self;
|
||||
let any_store_field = Ident::new("AnyStoreField", Span::call_site());
|
||||
let trait_name = Ident::new(&format!("{name}StoreFields"), name.span());
|
||||
let generics_with_orig = {
|
||||
let params = &generics.params;
|
||||
quote! { <#any_store_field, #params> }
|
||||
};
|
||||
let params = &generics.params;
|
||||
let generics_with_orig = quote! { <#any_store_field, #params> };
|
||||
let where_with_orig = {
|
||||
generics
|
||||
.where_clause
|
||||
@@ -140,13 +138,13 @@ impl ToTokens for Model {
|
||||
|
||||
// read access
|
||||
tokens.extend(quote! {
|
||||
#vis trait #trait_name <AnyStoreField>
|
||||
#vis trait #trait_name <AnyStoreField, #params>
|
||||
#where_with_orig
|
||||
{
|
||||
#(#trait_fields)*
|
||||
}
|
||||
|
||||
impl #generics_with_orig #trait_name <AnyStoreField> for AnyStoreField
|
||||
impl #generics_with_orig #trait_name <AnyStoreField, #params> for AnyStoreField
|
||||
#where_with_orig
|
||||
{
|
||||
#(#read_fields)*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_router"
|
||||
version = "0.8.2"
|
||||
version = { workspace = true }
|
||||
authors = ["Greg Johnston", "Ben Wishovich"]
|
||||
license = "MIT"
|
||||
readme = "../README.md"
|
||||
@@ -20,11 +20,11 @@ tachys = { workspace = true, features = ["reactive_graph"] }
|
||||
futures = { workspace = true, default-features = true }
|
||||
url = { workspace = true, default-features = true }
|
||||
js-sys = { workspace = true, default-features = true }
|
||||
wasm-bindgen = { workspace = true , default-features = true }
|
||||
tracing = { optional = true , workspace = true, default-features = true }
|
||||
wasm-bindgen = { workspace = true, default-features = true }
|
||||
tracing = { optional = true, workspace = true, default-features = true }
|
||||
send_wrapper = { workspace = true, default-features = true }
|
||||
thiserror = { workspace = true , default-features = true }
|
||||
percent-encoding = { optional = true , workspace = true, default-features = true }
|
||||
thiserror = { workspace = true, default-features = true }
|
||||
percent-encoding = { optional = true, workspace = true, default-features = true }
|
||||
gloo-net = { workspace = true, default-features = true }
|
||||
|
||||
[dependencies.web-sys]
|
||||
|
||||
@@ -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::<FROM_SERVER>(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<const FROM_SERVER: bool>(
|
||||
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!(
|
||||
"<FlatRoutes> 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
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -67,10 +67,32 @@ impl Url {
|
||||
}
|
||||
|
||||
pub fn hash(&self) -> &str {
|
||||
#[cfg(all(feature = "ssr", any(debug_assertions, leptos_debuginfo)))]
|
||||
{
|
||||
#[cfg(feature = "tracing")]
|
||||
tracing::warn!(
|
||||
"Reading hash on the server can lead to hydration errors."
|
||||
);
|
||||
#[cfg(not(feature = "tracing"))]
|
||||
eprintln!(
|
||||
"Reading hash on the server can lead to hydration errors."
|
||||
);
|
||||
}
|
||||
&self.hash
|
||||
}
|
||||
|
||||
pub fn hash_mut(&mut self) -> &mut String {
|
||||
#[cfg(all(feature = "ssr", any(debug_assertions, leptos_debuginfo)))]
|
||||
{
|
||||
#[cfg(feature = "tracing")]
|
||||
tracing::warn!(
|
||||
"Reading hash on the server can lead to hydration errors."
|
||||
);
|
||||
#[cfg(not(feature = "tracing"))]
|
||||
eprintln!(
|
||||
"Reading hash on the server can lead to hydration errors."
|
||||
);
|
||||
}
|
||||
&mut self.hash
|
||||
}
|
||||
|
||||
@@ -173,7 +195,7 @@ impl Location {
|
||||
let state = state.into();
|
||||
let pathname = Memo::new(move |_| url.with(|url| url.path.clone()));
|
||||
let search = Memo::new(move |_| url.with(|url| url.search.clone()));
|
||||
let hash = Memo::new(move |_| url.with(|url| url.hash.clone()));
|
||||
let hash = Memo::new(move |_| url.with(|url| url.hash().to_string()));
|
||||
let query =
|
||||
Memo::new(move |_| url.with(|url| url.search_params.clone()));
|
||||
Location {
|
||||
|
||||
@@ -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<Box<dyn Future<Output = AnyView>>>,
|
||||
preload: for<'a> fn(&'a Erased) -> Pin<Box<dyn Future<Output = ()> + 'a>>,
|
||||
}
|
||||
|
||||
@@ -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<T> ChooseView for Lazy<T>
|
||||
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<Output = AnyView>;
|
||||
fn view(this: Self) -> impl Future<Output = AnyView>;
|
||||
|
||||
fn preload() -> impl Future<Output = ()> {
|
||||
async {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Lazy<T> {
|
||||
ty: PhantomData<T>,
|
||||
data: ArcStoredValue<Option<T>>,
|
||||
}
|
||||
|
||||
impl<T> Clone for Lazy<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self { ty: self.ty }
|
||||
Self {
|
||||
ty: self.ty,
|
||||
data: self.data.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +74,7 @@ impl<T> Default for Lazy<T> {
|
||||
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(),
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ where
|
||||
base,
|
||||
&mut loaders,
|
||||
&mut outlets,
|
||||
&outer_owner,
|
||||
);
|
||||
drop(url);
|
||||
|
||||
@@ -180,7 +179,6 @@ where
|
||||
&mut preloaders,
|
||||
&mut full_loaders,
|
||||
&mut state.outlets,
|
||||
&self.outer_owner,
|
||||
self.set_is_routing.is_some(),
|
||||
0,
|
||||
);
|
||||
@@ -340,7 +338,6 @@ where
|
||||
base,
|
||||
&mut loaders,
|
||||
&mut outlets,
|
||||
&outer_owner,
|
||||
);
|
||||
|
||||
// outlets will not send their views if the loaders are never polled
|
||||
@@ -394,7 +391,6 @@ where
|
||||
base,
|
||||
&mut loaders,
|
||||
&mut outlets,
|
||||
&outer_owner,
|
||||
);
|
||||
|
||||
// outlets will not send their views if the loaders are never polled
|
||||
@@ -448,14 +444,13 @@ where
|
||||
base,
|
||||
&mut loaders,
|
||||
&mut outlets,
|
||||
&outer_owner,
|
||||
);
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -471,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::<true>(cursor, position),
|
||||
));
|
||||
|
||||
NestedRouteViewState {
|
||||
path,
|
||||
current_url,
|
||||
outlets,
|
||||
view,
|
||||
outer_owner,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_owned(self) -> Self::Owned {
|
||||
self
|
||||
}
|
||||
@@ -483,10 +529,10 @@ pub(crate) struct RouteContext {
|
||||
trigger: ArcTrigger,
|
||||
url: ArcRwSignal<Url>,
|
||||
params: ArcRwSignal<ParamsMap>,
|
||||
owner: Owner,
|
||||
pub matched: ArcRwSignal<String>,
|
||||
base: Option<Oco<'static, str>>,
|
||||
view_fn: Arc<Mutex<OutletViewFn>>,
|
||||
owner: Arc<Mutex<Option<Owner>>>,
|
||||
child: ChildRoute,
|
||||
}
|
||||
|
||||
@@ -500,7 +546,6 @@ impl Debug for RouteContext {
|
||||
.field("trigger", &self.trigger)
|
||||
.field("url", &self.url)
|
||||
.field("params", &self.params)
|
||||
.field("owner", &self.owner.debug_id())
|
||||
.field("matched", &self.matched)
|
||||
.field("base", &self.base)
|
||||
.finish_non_exhaustive()
|
||||
@@ -514,10 +559,10 @@ impl Clone for RouteContext {
|
||||
id: self.id,
|
||||
trigger: self.trigger.clone(),
|
||||
params: self.params.clone(),
|
||||
owner: self.owner.clone(),
|
||||
matched: self.matched.clone(),
|
||||
base: self.base.clone(),
|
||||
view_fn: Arc::clone(&self.view_fn),
|
||||
owner: Arc::clone(&self.owner),
|
||||
child: self.child.clone(),
|
||||
}
|
||||
}
|
||||
@@ -530,7 +575,6 @@ trait AddNestedRoute {
|
||||
base: Option<Oco<'static, str>>,
|
||||
loaders: &mut Vec<Pin<Box<dyn Future<Output = ArcTrigger>>>>,
|
||||
outlets: &mut Vec<RouteContext>,
|
||||
parent: &Owner,
|
||||
);
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -540,9 +584,8 @@ trait AddNestedRoute {
|
||||
base: Option<Oco<'static, str>>,
|
||||
items: &mut usize,
|
||||
loaders: &mut Vec<Pin<Box<dyn Future<Output = ArcTrigger>>>>,
|
||||
full_loaders: &mut Vec<oneshot::Receiver<()>>,
|
||||
full_loaders: &mut Vec<oneshot::Receiver<Option<Owner>>>,
|
||||
outlets: &mut Vec<RouteContext>,
|
||||
parent: &Owner,
|
||||
set_is_routing: bool,
|
||||
level: u8,
|
||||
) -> u8;
|
||||
@@ -558,15 +601,9 @@ where
|
||||
base: Option<Oco<'static, str>>,
|
||||
loaders: &mut Vec<Pin<Box<dyn Future<Output = ArcTrigger>>>>,
|
||||
outlets: &mut Vec<RouteContext>,
|
||||
parent: &Owner,
|
||||
) {
|
||||
let orig_url = url;
|
||||
|
||||
// each Outlet gets its own owner, so it can inherit context from its parent route,
|
||||
// a new owner will be constructed if a different route replaces this one in the outlet,
|
||||
// so that any signals it creates or context it provides will be cleaned up
|
||||
let owner = parent.child();
|
||||
|
||||
// the params signal can be updated to allow the same outlet to update to changes in the
|
||||
// params, even if there's not a route match change
|
||||
let params = ArcRwSignal::new(self.to_params().into_iter().collect());
|
||||
@@ -624,13 +661,13 @@ where
|
||||
url,
|
||||
trigger: trigger.clone(),
|
||||
params,
|
||||
owner: owner.clone(),
|
||||
matched,
|
||||
view_fn: Arc::new(Mutex::new(Box::new(|_owner| {
|
||||
Suspend::new(Box::pin(async { ().into_any() }))
|
||||
}))),
|
||||
base: base.clone(),
|
||||
child: ChildRoute(Arc::new(Mutex::new(None))),
|
||||
owner: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
if !outlets.is_empty() {
|
||||
let prev_index = outlets.len().saturating_sub(1);
|
||||
@@ -646,15 +683,21 @@ where
|
||||
let url = outlet.url.clone();
|
||||
let matched = Matched(matched_including_parents);
|
||||
let view_fn = Arc::clone(&outlet.view_fn);
|
||||
let route_owner = Arc::clone(&outlet.owner);
|
||||
let outlet = outlet.clone();
|
||||
let params = params_including_parents.clone();
|
||||
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() =
|
||||
Box::new(move |owner_where_used| {
|
||||
*route_owner.lock().or_poisoned() =
|
||||
Some(owner_where_used.clone());
|
||||
let view = view.clone();
|
||||
let child = child.clone();
|
||||
let params = params.clone();
|
||||
@@ -662,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());
|
||||
@@ -696,7 +739,7 @@ where
|
||||
// this is important because to build the view, we need access to the outlet
|
||||
// and the outlet will be returned from building this child
|
||||
if let Some(child) = child {
|
||||
child.build_nested_route(orig_url, base, loaders, outlets, &owner);
|
||||
child.build_nested_route(orig_url, base, loaders, outlets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,9 +750,8 @@ where
|
||||
base: Option<Oco<'static, str>>,
|
||||
items: &mut usize,
|
||||
preloaders: &mut Vec<Pin<Box<dyn Future<Output = ArcTrigger>>>>,
|
||||
full_loaders: &mut Vec<oneshot::Receiver<()>>,
|
||||
full_loaders: &mut Vec<oneshot::Receiver<Option<Owner>>>,
|
||||
outlets: &mut Vec<RouteContext>,
|
||||
parent: &Owner,
|
||||
set_is_routing: bool,
|
||||
level: u8,
|
||||
) -> u8 {
|
||||
@@ -718,11 +760,17 @@ where
|
||||
.take(*items)
|
||||
.map(|route| (route.params.clone(), route.matched.clone()))
|
||||
.unzip();
|
||||
|
||||
if outlets.get(*items).is_some() && *items > 0 {
|
||||
*outlets[*items - 1].child.0.lock().or_poisoned() =
|
||||
Some(outlets[*items].clone());
|
||||
}
|
||||
|
||||
let current = outlets.get_mut(*items);
|
||||
match current {
|
||||
// if there's nothing currently in the routes at this point, build from here
|
||||
None => {
|
||||
self.build_nested_route(url, base, preloaders, outlets, parent);
|
||||
self.build_nested_route(url, base, preloaders, outlets);
|
||||
level
|
||||
}
|
||||
Some(current) => {
|
||||
@@ -787,11 +835,6 @@ where
|
||||
})
|
||||
};
|
||||
|
||||
// assign a new owner, so that contexts and signals owned by the previous route
|
||||
// in this outlet can be dropped
|
||||
let mut old_owner =
|
||||
Some(mem::replace(&mut current.owner, parent.child()));
|
||||
let owner = current.owner.clone();
|
||||
let (full_tx, full_rx) = oneshot::channel();
|
||||
let full_tx = Mutex::new(Some(full_tx));
|
||||
full_loaders.push(full_rx);
|
||||
@@ -801,22 +844,28 @@ where
|
||||
// and notify the trigger so that the reactive view inside the Outlet tracking
|
||||
// the trigger runs again
|
||||
preloaders.push(Box::pin(ScopedFuture::new({
|
||||
let owner = owner.clone();
|
||||
let trigger = current.trigger.clone();
|
||||
let url = current.url.clone();
|
||||
let matched = Matched(matched_including_parents);
|
||||
let view_fn = Arc::clone(¤t.view_fn);
|
||||
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 owner = owner.clone();
|
||||
let prev_owner = route_owner
|
||||
.lock()
|
||||
.or_poisoned()
|
||||
.replace(owner_where_used.clone());
|
||||
let view = view.clone();
|
||||
let full_tx =
|
||||
full_tx.lock().or_poisoned().take();
|
||||
let old_owner = old_owner.take();
|
||||
let child = child.clone();
|
||||
let params =
|
||||
params_including_parents.clone();
|
||||
@@ -841,15 +890,13 @@ where
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
let view = view.await;
|
||||
if let Some(old_owner) = old_owner {
|
||||
old_owner.cleanup();
|
||||
}
|
||||
|
||||
if let Some(tx) = full_tx {
|
||||
_ = tx.send(());
|
||||
_ = tx.send(prev_owner);
|
||||
}
|
||||
owner.with(|| {
|
||||
owner_where_used.with(|| {
|
||||
OwnedView::new(view).into_any()
|
||||
})
|
||||
}))
|
||||
@@ -868,9 +915,10 @@ where
|
||||
|
||||
// if this children has matches, then rebuild the lower section of the tree
|
||||
if let Some(child) = child {
|
||||
child.build_nested_route(
|
||||
url, base, preloaders, outlets, &owner,
|
||||
);
|
||||
child
|
||||
.build_nested_route(url, base, preloaders, outlets);
|
||||
} else {
|
||||
*outlets[*items].child.0.lock().or_poisoned() = None;
|
||||
}
|
||||
|
||||
return level;
|
||||
@@ -882,7 +930,6 @@ where
|
||||
current.params.set(new_params);
|
||||
current.url.set(url.to_owned());
|
||||
if let Some(child) = child {
|
||||
let owner = current.owner.clone();
|
||||
*items += 1;
|
||||
child.rebuild_nested_route(
|
||||
url,
|
||||
@@ -891,11 +938,11 @@ where
|
||||
preloaders,
|
||||
full_loaders,
|
||||
outlets,
|
||||
&owner,
|
||||
set_is_routing,
|
||||
level + 1,
|
||||
)
|
||||
} else {
|
||||
*current.child.0.lock().or_poisoned() = None;
|
||||
level
|
||||
}
|
||||
}
|
||||
@@ -933,13 +980,13 @@ fn top_level_outlet(outlets: &[RouteContext], outer_owner: &Owner) -> AnyView {
|
||||
let child = outlet.child.clone();
|
||||
let view_fn = outlet.view_fn.clone();
|
||||
let trigger = outlet.trigger.clone();
|
||||
let owner = outer_owner.child();
|
||||
outer_owner.clone().with(|| {
|
||||
provide_context(child.clone());
|
||||
let outer_owner = outer_owner.clone();
|
||||
(move || {
|
||||
trigger.track();
|
||||
let mut view_fn = view_fn.lock().or_poisoned();
|
||||
view_fn(owner.clone())
|
||||
view_fn(outer_owner.child())
|
||||
})
|
||||
.into_any()
|
||||
})
|
||||
@@ -953,13 +1000,13 @@ where
|
||||
{
|
||||
let ChildRoute(child) = use_context()
|
||||
.expect("<Outlet/> used without RouteContext being provided.");
|
||||
let owner = Owner::new();
|
||||
let child = child.lock().or_poisoned().clone();
|
||||
let outer_owner = Owner::current().unwrap();
|
||||
child.map(|child| {
|
||||
move || {
|
||||
child.trigger.track();
|
||||
let mut view_fn = child.view_fn.lock().or_poisoned();
|
||||
view_fn(owner.clone())
|
||||
view_fn(outer_owner.child())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user