feat: experimental islands (#1660)

This commit is contained in:
Greg Johnston 2023-09-08 16:33:00 -04:00 committed by GitHub
parent b9a1fb7743
commit 238d61ce1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 2602 additions and 510 deletions

View file

@ -5,9 +5,13 @@ edition = "2021"
[dependencies]
l021 = { package = "leptos", version = "0.2.1" }
leptos = { path = "../leptos", features = ["ssr"] }
leptos = { path = "../leptos", features = [
"ssr",
"nightly",
"experimental-islands",
] }
sycamore = { version = "0.8", features = ["ssr"] }
yew = { git = "https://github.com/yewstack/yew", features = ["ssr"] }
yew = { version = "0.20", features = ["ssr"] }
tokio-test = "0.4"
miniserde = "0.1"
gloo = "0.8"

View file

@ -16,7 +16,10 @@ actix-web = { version = "4", optional = true, features = ["macros"] }
console_log = "1"
console_error_panic_hook = "0.1"
cfg-if = "1"
leptos = { path = "../../leptos", features = ["nightly"] }
leptos = { path = "../../leptos", features = [
"nightly",
"experimental-islands",
] }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router", features = ["nightly"] }
@ -41,6 +44,12 @@ ssr = [
"leptos_router/ssr",
]
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
[package.metadata.cargo-all-features]
denylist = ["actix-files", "actix-web", "leptos_actix"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
@ -88,3 +97,5 @@ lib-features = ["hydrate"]
#
# Optional. Defaults to false.
lib-default-features = false
lib-profile-release = "wasm-release"

View file

@ -0,0 +1,3 @@
[unstable]
build-std = ["std", "panic_abort", "core", "alloc"]
build-std-features = ["panic_immediate_abort"]

View file

@ -0,0 +1,108 @@
[package]
name = "hackernews"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[profile.release]
codegen-units = 1
lto = true
[dependencies]
console_log = "1.0.0"
console_error_panic_hook = "0.1.7"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", features = [
"nightly",
"experimental-islands",
] }
leptos_axum = { path = "../../integrations/axum", optional = true, features = [
"experimental-islands",
] }
leptos_meta = { path = "../../meta", features = ["nightly"] }
leptos_router = { path = "../../router", features = ["nightly"] }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true, features = ["http2"] }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = [
"fs",
"compression-br",
], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"
wee_alloc = "0.4.5"
lazy_static = "1.4.0"
[features]
default = []
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:http",
"leptos/ssr",
"leptos_axum",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[package.metadata.cargo-all-features]
denylist = ["axum", "tower", "tower-http", "tokio", "http", "leptos_axum"]
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
output-name = "hackernews"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
#site-addr = "127.0.0.1:3000"
site-addr = "0.0.0.0:8080"
# 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.
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha 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

View file

@ -0,0 +1,25 @@
FROM rustlang/rust:nightly-bullseye as builder
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
#RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
#RUN cp cargo-binstall /usr/local/cargo/bin
#RUN cargo binstall cargo-leptos -y
#RUN rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
#RUN rustup target add wasm32-unknown-unknown
RUN mkdir -p /app
WORKDIR /app
COPY . .
RUN cargo build --release --no-default-features --features=ssr
RUN ls -l /app/target
FROM rustlang/rust:nightly-bullseye as runner
COPY --from=builder /app/target/release/hackernews /app/
COPY --from=builder /app/pkg /app
COPY --from=builder /app/Cargo.toml /app/
WORKDIR /app
ENV RUST_LOG="info"
ENV LEPTOS_OUTPUT_NAME="hackernews"
ENV APP_ENVIRONMENT="production"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT="site"
EXPOSE 8080
CMD ["/app/hackernews"]

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Greg Johnston
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.

View file

@ -0,0 +1 @@
extend = [{ path = "../cargo-make/main.toml" }]

View file

@ -0,0 +1,43 @@
# Leptos Hacker News Example with Axum
This example creates a basic clone of the Hacker News site. It showcases Leptos' ability to create both a client-side rendered app, and a server side rendered app with hydration, in a single repository. This repo differs from the main Hacker News example by using Axum as it's server.
## Client Side Rendering
To run it as a Client Side App, you can issue `trunk serve --open` in the root. This will build the entire
app into one CSR bundle. Make sure you have trunk installed with `cargo install trunk`.
## Server Side Rendering with cargo-leptos
cargo-leptos is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
1. Install cargo-leptos
```bash
cargo install --locked cargo-leptos
```
2. Build the site in watch mode, recompiling on file changes
```bash
cargo leptos watch
```
Open browser on [http://localhost:3000/](http://localhost:3000/)
3. When ready to deploy, run
```bash
cargo leptos build --release
```
## Server Side Rendering without cargo-leptos
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes..
1. Install wasm-pack
```bash
cargo install wasm-pack
```
2. Build the Webassembly used to hydrate the HTML from the server
```bash
wasm-pack build --target=web --debug --no-default-features --features=hydrate
```
3. Run the server to serve the Webassembly, JS, and HTML
```bash
cargo run --no-default-features --features=ssr
```

View file

@ -0,0 +1,8 @@
wasm-pack build --target=web --features=hydrate --release
cd pkg
rm *.br
cp hackernews.js hackernews.unmin.js
cat hackernews.unmin.js | esbuild > hackernews.js
brotli hackernews.js
brotli hackernews_bg.wasm
brotli style.css

View file

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="css" href="/style.css"/>
</head>
<body></body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,62 @@
#![allow(unused)]
use leptos::Serializable;
use serde::{Deserialize, Serialize};
pub fn story(path: &str) -> String {
format!("https://node-hnapi.herokuapp.com/{path}")
}
pub fn user(path: &str) -> String {
format!("https://hacker-news.firebaseio.com/v0/user/{path}.json")
}
lazy_static::lazy_static! {
static ref CLIENT: reqwest::Client = reqwest::Client::new();
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serializable,
{
let json = CLIENT.get(path).send().await.ok()?.text().await.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct Story {
pub id: usize,
pub title: String,
pub points: Option<i32>,
pub user: Option<String>,
pub time: usize,
pub time_ago: String,
#[serde(alias = "type")]
pub story_type: String,
pub url: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub comments: Option<Vec<Comment>>,
pub comments_count: Option<usize>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct Comment {
pub id: usize,
pub level: usize,
pub user: Option<String>,
pub time: usize,
pub time_ago: String,
pub content: Option<String>,
pub comments: Vec<Comment>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
pub struct User {
pub created: usize,
pub id: String,
pub karma: i32,
pub about: Option<String>,
}

View file

@ -0,0 +1,28 @@
use leptos::{view, Errors, For, IntoView, RwSignal, View};
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
pub fn error_template(errors: Option<RwSignal<Errors>>) -> View {
let Some(errors) = errors else {
panic!("No Errors found and we expected errors!");
};
view! {
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each=errors
// a unique key for each item as a reference
key=|(key, _)| key.clone()
// renders each item to a view
view= move | (_, error)| {
let error_string = error.to_string();
view! {
<p>"Error: " {error_string}</p>
}
}
/>
}
.into_view()
}

View file

@ -0,0 +1,44 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::{LeptosOptions};
use crate::error_template::error_template;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else{
let handler = leptos_axum::render_app_to_stream(options.to_owned(), || error_template( None));
handler(req).await.into_response()
}
}
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
}
}
}

View file

@ -0,0 +1,63 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
http::{Request, Response, StatusCode, Uri},
};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_handler(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
let res = get_static_file(uri.clone(), "/pkg").await?;
if res.status() == StatusCode::NOT_FOUND {
// try with `.html`
// TODO: handle if the Uri has query parameters
match format!("{}.html", uri).parse() {
Ok(uri_html) => get_static_file(uri_html, "/pkg").await,
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string())),
}
} else {
Ok(res)
}
}
pub async fn get_static_file_handler(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
let res = get_static_file(uri.clone(), "/static").await?;
if res.status() == StatusCode::NOT_FOUND {
Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string()))
} else {
Ok(res)
}
}
async fn get_static_file(uri: Uri, base: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
let req = Request::builder().uri(&uri).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// When run normally, the root should be the crate root
if base == "/static" {
match ServeDir::new("./static").oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
))
}
} else if base == "/pkg" {
match ServeDir::new("./pkg").oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
} else{
Err((StatusCode::NOT_FOUND, "Not Found".to_string()))
}
}
}
}

View file

@ -0,0 +1,52 @@
#![feature(lazy_cell)]
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
mod api;
pub mod error_template;
pub mod fallback;
mod routes;
use routes::{nav::*, stories::*, story::*, users::*};
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
view! {
<Stylesheet id="leptos" href="/pkg/hackernews.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />
<main>
<Routes>
<Route path="users/:id" view=User ssr=SsrMode::InOrder/>
<Route path="stories/:id" view=Story ssr=SsrMode::InOrder/>
<Route path=":stories?" view=Stories ssr=SsrMode::InOrder/>
</Routes>
</main>
</Router>
}
}
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
cfg_if! {
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen;
extern crate wee_alloc;
// Use `wee_alloc` as the global allocator.
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub fn hydrate() {
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
leptos::leptos_dom::HydrationCtx::stop_hydrating();
}
}
}

View file

@ -0,0 +1,47 @@
#[cfg(feature = "ssr")]
mod ssr_imports {
pub use axum::{routing::get, Router};
pub use hackernews::fallback::file_and_error_handler;
pub use leptos::*;
pub use leptos_axum::{generate_route_list, LeptosRoutes};
pub use tower_http::{compression::CompressionLayer, services::ServeFile};
}
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use hackernews::*;
use ssr_imports::*;
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(|| view! { <App/> }).await;
// build our application with a route
let app = Router::new()
.route("/favicon.ico", get(file_and_error_handler))
.leptos_routes(&leptos_options, routes, || view! { <App/> })
.fallback(file_and_error_handler)
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
logging::log!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
// client-only stuff for Trunk
#[cfg(not(feature = "ssr"))]
pub fn main() {
use hackernews::*;
use leptos::*;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <App/> }
});
}

View file

@ -0,0 +1,4 @@
pub mod nav;
pub mod stories;
pub mod story;
pub mod users;

View file

@ -0,0 +1,30 @@
use leptos::{component, view, IntoView};
use leptos_router::*;
#[component]
pub fn Nav() -> impl IntoView {
view! {
<header class="header">
<nav class="inner">
<A href="/home">
<strong>"HN"</strong>
</A>
<A href="/new">
<strong>"New"</strong>
</A>
<A href="/show">
<strong>"Show"</strong>
</A>
<A href="/ask">
<strong>"Ask"</strong>
</A>
<A href="/job">
<strong>"Jobs"</strong>
</A>
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
"Built with Leptos"
</a>
</nav>
</header>
}
}

View file

@ -0,0 +1,164 @@
use crate::api;
use leptos::*;
use leptos_router::*;
fn category(from: &str) -> String {
match from {
"new" => "newest",
"show" => "show",
"ask" => "ask",
"job" => "jobs",
_ => "news",
}
.to_string()
}
#[server(FetchStories, "/api")]
pub async fn fetch_stories(
story_type: String,
page: usize,
) -> Result<Vec<api::Story>, ServerFnError> {
let path = format!("{}?page={}", category(&story_type), page);
Ok(api::fetch_api::<Vec<api::Story>>(&api::story(&path))
.await
.unwrap_or_default())
}
#[component]
pub fn Stories() -> impl IntoView {
let query = use_query_map();
let params = use_params_map();
let page = move || {
query
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
.unwrap_or(1)
};
let story_type = move || {
params
.with(|p| p.get("stories").cloned())
.unwrap_or_else(|| "top".to_string())
};
let stories = create_resource(
move || (page(), story_type()),
move |(page, story_type)| fetch_stories(category(&story_type), page),
);
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || {
pending()
|| stories
.map(|stories| {
stories.as_ref().map(|s| s.len() < 28).unwrap_or_default()
})
.unwrap_or_default()
};
view! {
<div class="news-view">
<div class="news-list-nav">
<span>
{move || if page() > 1 {
view! {
<a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1)
attr:aria_label="Previous Page"
>
"< prev"
</a>
}.into_any()
} else {
view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
}.into_any()
}}
</span>
<span>"page " {page}</span>
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
"more >"
</a>
</span>
</div>
<main class="news-list">
<div>
<Transition
fallback=|| ()
set_pending=set_pending.into()
>
{move || stories.get().map(|story| story.map(|stories| view! {
<ul>
<For
each=move || stories.clone()
key=|story| story.id
view=move |story: api::Story| {
view! {
<Story story/>
}
}
/>
</ul>
}))}
</Transition>
</div>
</main>
</div>
}
}
#[component]
fn Story(story: api::Story) -> impl IntoView {
view! {
<li class="news-item">
<span class="score">{story.points}</span>
<span class="title">
{if !story.url.starts_with("item?id=") {
view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"("{story.domain}")"</span>
</span>
}.into_view()
} else {
let title = story.title.clone();
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
<br />
<span class="meta">
{if story.story_type != "job" {
view! {
<span>
{"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
{format!(" {} | ", story.time_ago)}
<A href=format!("/stories/{}", story.id)>
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"discuss".into()
}}
</A>
</span>
}.into_view()
} else {
let title = story.title.clone();
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
{(story.story_type != "link").then(|| view! {
" "
<span class="label">{story.story_type}</span>
})}
</li>
}
}

View file

@ -0,0 +1,137 @@
use crate::api;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use std::cell::RefCell;
#[server(FetchStory, "/api")]
pub async fn fetch_story(
id: String,
) -> Result<RefCell<Option<api::Story>>, ServerFnError> {
Ok(RefCell::new(
api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await,
))
}
#[component]
pub fn Story() -> impl IntoView {
let params = use_params_map();
let story = create_resource(
move || params().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
Ok(RefCell::new(None))
} else {
fetch_story(id).await
}
},
);
let meta_description = move || {
story
.map(|story| {
story
.as_ref()
.map(|story| {
story.borrow().as_ref().map(|story| story.title.clone())
})
.ok()
})
.flatten()
.flatten()
.unwrap_or_else(|| "Loading story...".to_string())
};
let story_view = move || {
story.map(|story| {
story.as_ref().ok().and_then(|story| {
let story: Option<api::Story> = story.borrow_mut().take();
story.map(|story| {
view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
{story.comments.unwrap_or_default().into_iter()
.map(|comment: api::Comment| view! { <Comment comment /> })
.collect_view()}
</ul>
</div>
</div>
}})})})
};
view! {
<Meta name="description" content=meta_description/>
<Suspense fallback=|| ()>
{story_view}
</Suspense>
}
}
#[island]
pub fn Toggle(children: Children) -> impl IntoView {
let (open, set_open) = create_signal(true);
view! {
<div class="toggle" class:open=open>
<a on:click=move |_| set_open.update(|n| *n = !*n)>
{move || if open() {
"[-]"
} else {
"[+] comments collapsed"
}}
</a>
</div>
<ul
class="comment-children"
style:display=move || if open() {
"block"
} else {
"none"
}
>
{children()}
</ul>
}
}
#[component]
pub fn Comment(comment: api::Comment) -> impl IntoView {
view! {
<li class="comment">
<div class="by">
<A href=format!("/users/{}", comment.user.clone().unwrap_or_default())>{comment.user.clone()}</A>
{format!(" {}", comment.time_ago)}
</div>
<div class="text" inner_html=comment.content></div>
{(!comment.comments.is_empty()).then(|| {
view! {
<Toggle>
{comment.comments.into_iter()
.map(|comment: api::Comment| view! { <Comment comment /> })
.collect_view()}
</Toggle>
}
})}
</li>
}
}

View file

@ -0,0 +1,54 @@
#[allow(unused)] // User is unused in WASM build
use crate::api::{self, User};
use leptos::*;
use leptos_router::*;
#[server(FetchUser, "/api")]
pub async fn fetch_user(
id: String,
) -> Result<Option<api::User>, ServerFnError> {
Ok(api::fetch_api::<User>(&api::user(&id)).await)
}
#[component]
pub fn User() -> impl IntoView {
let params = use_params_map();
let user = create_resource(
move || params().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
Ok(None)
} else {
fetch_user(id).await
}
},
);
view! {
<div class="user-view">
<Suspense fallback=|| ()>
{move || user.get().map(|user| user.map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_view(),
Some(user) => view! {
<div>
<h1>"User: " {&user.id}</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>
}.into_view()
}))}
</Suspense>
</div>
}
}

View file

@ -0,0 +1,326 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size: 15px;
background-color: #f2f3f5;
margin: 0;
padding-top: 55px;
color: #34495e;
overflow-y: scroll
}
a {
color: #34495e;
text-decoration: none
}
.header {
background-color: #335d92;
position: fixed;
z-index: 999;
height: 55px;
top: 0;
left: 0;
right: 0
}
.header .inner {
max-width: 800px;
box-sizing: border-box;
margin: 0 auto;
padding: 15px 5px
}
.header a {
color: rgba(255, 255, 255, .8);
line-height: 24px;
transition: color .15s ease;
display: inline-block;
vertical-align: middle;
font-weight: 300;
letter-spacing: .075em;
margin-right: 1.8em
}
.header a:hover {
color: #fff
}
.header a.active {
color: #fff;
font-weight: 400
}
.header a:nth-child(6) {
margin-right: 0
}
.header .github {
color: #fff;
font-size: .9em;
margin: 0;
float: right
}
.logo {
width: 24px;
margin-right: 10px;
display: inline-block;
vertical-align: middle
}
.view {
max-width: 800px;
margin: 0 auto;
position: relative
}
.fade-enter-active,
.fade-exit-active {
transition: all .2s ease
}
.fade-enter,
.fade-exit-active {
opacity: 0
}
@media (max-width:860px) {
.header .inner {
padding: 15px 30px
}
}
@media (max-width:600px) {
.header .inner {
padding: 15px
}
.header a {
margin-right: 1em
}
.header .github {
display: none
}
}
.news-view {
padding-top: 45px
}
.news-list,
.news-list-nav {
background-color: #fff;
border-radius: 2px
}
.news-list-nav {
padding: 15px 30px;
position: fixed;
text-align: center;
top: 55px;
left: 0;
right: 0;
z-index: 998;
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
}
.news-list-nav .page-link {
margin: 0 1em
}
.news-list-nav .disabled {
color: #aaa
}
.news-list {
position: absolute;
margin: 30px 0;
width: 100%;
transition: all .5s cubic-bezier(.55, 0, .1, 1)
}
.news-list ul {
list-style-type: none;
padding: 0;
margin: 0
}
@media (max-width:600px) {
.news-list {
margin: 10px 0
}
}
.news-item {
background-color: #fff;
padding: 20px 30px 20px 80px;
border-bottom: 1px solid #eee;
position: relative;
line-height: 20px
}
.news-item .score {
color: #335d92;
font-size: 1.1em;
font-weight: 700;
position: absolute;
top: 50%;
left: 0;
width: 80px;
text-align: center;
margin-top: -10px
}
.news-item .host,
.news-item .meta {
font-size: .85em;
color: #626262
}
.news-item .host a,
.news-item .meta a {
color: #626262;
text-decoration: underline
}
.news-item .host a:hover,
.news-item .meta a:hover {
color: #335d92
}
.item-view-header {
background-color: #fff;
padding: 1.8em 2em 1em;
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
}
.item-view-header h1 {
display: inline;
font-size: 1.5em;
margin: 0;
margin-right: .5em
}
.item-view-header .host,
.item-view-header .meta,
.item-view-header .meta a {
color: #626262
}
.item-view-header .meta a {
text-decoration: underline
}
.item-view-comments {
background-color: #fff;
margin-top: 10px;
padding: 0 2em .5em
}
.item-view-comments-header {
margin: 0;
font-size: 1.1em;
padding: 1em 0;
position: relative
}
.item-view-comments-header .spinner {
display: inline-block;
margin: -15px 0
}
.comment-children {
list-style-type: none;
padding: 0;
margin: 0
}
@media (max-width:600px) {
.item-view-header h1 {
font-size: 1.25em
}
}
.comment-children .comment-children {
margin-left: 1.5em
}
.comment {
border-top: 1px solid #eee;
position: relative
}
.comment .by,
.comment .text,
.comment .toggle {
font-size: .9em;
margin: 1em 0
}
.comment .by {
color: #626262
}
.comment .by a {
color: #626262;
text-decoration: underline
}
.comment .text {
overflow-wrap: break-word
}
.comment .text a:hover {
color: #335d92
}
.comment .text pre {
white-space: pre-wrap
}
.comment .toggle {
background-color: #fffbf2;
padding: .3em .5em;
border-radius: 4px
}
.comment .toggle a {
color: #626262;
cursor: pointer
}
.comment .toggle.open {
padding: 0;
background-color: transparent;
margin-bottom: -.5em
}
.user-view {
background-color: #fff;
box-sizing: border-box;
padding: 2em 3em
}
.user-view h1 {
margin: 0;
font-size: 1.5em
}
.user-view .meta {
list-style-type: none;
padding: 0
}
.user-view .label {
display: inline-block;
min-width: 4em
}
.user-view .about {
margin: 1em 0
}
.user-view .links a {
text-decoration: underline
}

View file

@ -23,3 +23,4 @@ tokio = { version = "1", features = ["rt"] }
[features]
nonce = ["leptos/nonce"]
experimental-islands = ["leptos_integration_utils/experimental-islands"]

View file

@ -25,3 +25,4 @@ once_cell = "1.17"
[features]
nonce = ["leptos/nonce"]
experimental-islands = ["leptos_integration_utils/experimental-islands"]

View file

@ -13,4 +13,7 @@ leptos = { workspace = true, features = ["ssr"] }
leptos_hot_reload = { workspace = true }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_config = { workspace = true }
tracing="0.1.37"
tracing = "0.1.37"
[features]
experimental-islands = []

View file

@ -79,6 +79,27 @@ pub fn html_parts_separated(
.as_ref()
.map(|meta| meta.dehydrate())
.unwrap_or_default();
let import_callback = if cfg!(feature = "experimental-islands") {
/* r#"() => {
for (let e of document.querySelectorAll("leptos-island")) {
let l = e.dataset.component;
console.log("hydrating island");
mod["_island_" + l];
}
mod.hydrate();
}"# */
r#"() => {
for (let e of document.querySelectorAll("leptos-island")) {
let l = e.dataset.component;
mod["_island_" + l](e);
}
mod.hydrate();
}
"#
//r#"()=>{for(let e of document.querySelectorAll("leptos-island")){let l=e.dataset.component;mod["_island_"+l](e)};mod.hydrate();}"#
} else {
"() => mod.hydrate()"
};
let head = format!(
r#"<!DOCTYPE html>
<html{html_metadata}>
@ -88,7 +109,21 @@ pub fn html_parts_separated(
{head}
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js"{nonce}>
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin=""{nonce}>
<script type="module"{nonce}>import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
<script type="module"{nonce}>
function idle(c) {{
if ("requestIdleCallback" in window) {{
window.requestIdleCallback(c);
}} else {{
c();
}}
}}
idle(() => {{
import('/{pkg_path}/{output_name}.js')
.then(mod => {{
mod.default('/{pkg_path}/{wasm_output_name}.wasm').then({import_callback});
}})
}});
</script>
{leptos_autoreload}
"#
);

View file

@ -22,3 +22,4 @@ parking_lot = "0.12.1"
[features]
nonce = ["leptos/nonce"]
experimental-islands = ["leptos_integration_utils/experimental-islands"]

View file

@ -10,6 +10,7 @@ readme = "../README.md"
[dependencies]
cfg-if = "1"
const_format = "0.2"
leptos_dom = { workspace = true }
leptos_macro = { workspace = true }
leptos_reactive = { workspace = true }
@ -18,24 +19,31 @@ leptos_config = { workspace = true }
tracing = "0.1"
typed-builder = "0.16"
typed-builder-macro = "0.16"
serde = { optional = true }
serde_json = { optional = true }
server_fn = { workspace = true }
web-sys = { version = "0.3.63", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
xxhash-rust = "0.8"
[features]
default = ["serde"]
template_macro = ["leptos_dom/web", "web-sys", "wasm-bindgen"]
template_macro = ["leptos_dom/web", "dep:web-sys", "dep:wasm-bindgen"]
csr = [
"leptos_dom/csr",
"leptos_macro/csr",
"leptos_reactive/csr",
"leptos_server/csr",
"dep:wasm-bindgen",
"dep:web-sys",
]
hydrate = [
"leptos_dom/hydrate",
"leptos_macro/hydrate",
"leptos_reactive/hydrate",
"leptos_server/hydrate",
"dep:wasm-bindgen",
"dep:web-sys",
]
default-tls = ["leptos_server/default-tls", "server_fn/default-tls"]
rustls = ["leptos_server/rustls", "server_fn/rustls"]
@ -58,6 +66,13 @@ miniserde = ["leptos_reactive/miniserde"]
rkyv = ["leptos_reactive/rkyv"]
tracing = ["leptos_macro/tracing"]
nonce = ["leptos_dom/nonce"]
experimental-islands = [
"leptos_dom/experimental-islands",
"leptos_macro/experimental-islands",
"leptos_reactive/experimental-islands",
"dep:serde",
"dep:serde_json",
]
[package.metadata.cargo-all-features]
denylist = [

View file

@ -155,10 +155,17 @@ pub mod ssr {
pub use leptos_dom::{ssr::*, ssr_in_order::*};
}
pub use leptos_dom::{
self, create_node_ref, document, ev, helpers::*, html, math, mount_to,
mount_to_body, nonce, svg, window, Attribute, Class, CollectView, Errors,
Fragment, HtmlElement, IntoAttribute, IntoClass, IntoProperty, IntoStyle,
IntoView, NodeRef, Property, View,
self, create_node_ref, document, ev,
helpers::{
event_target, event_target_checked, event_target_value,
request_animation_frame, request_animation_frame_with_handle,
request_idle_callback, request_idle_callback_with_handle, set_interval,
set_interval_with_handle, set_timeout, set_timeout_with_handle,
window_event_listener, window_event_listener_untyped,
},
html, math, mount_to, mount_to_body, nonce, svg, window, Attribute, Class,
CollectView, Errors, Fragment, HtmlElement, IntoAttribute, IntoClass,
IntoProperty, IntoStyle, IntoView, NodeRef, Property, View,
};
/// Utilities for simple isomorphic logging to the console or terminal.
pub mod logging {
@ -169,9 +176,11 @@ pub mod logging {
pub mod error {
pub use server_fn::error::{Error, Result};
}
#[cfg(all(target_arch = "wasm32", feature = "template_macro"))]
pub use leptos_macro::template;
#[cfg(not(any(target_arch = "wasm32", feature = "template_macro")))]
pub use leptos_macro::view as template;
pub use leptos_macro::{component, server, slot, view, Params};
pub use leptos_macro::{component, island, server, slot, view, Params};
pub use leptos_reactive::*;
pub use leptos_server::{
self, create_action, create_multi_action, create_server_action,
@ -179,8 +188,6 @@ pub use leptos_server::{
ServerFnErrorErr,
};
pub use server_fn::{self, ServerFn as _};
#[cfg(all(target_arch = "wasm32", feature = "template_macro"))]
pub use {leptos_macro::template, wasm_bindgen, web_sys};
mod error_boundary;
pub use error_boundary::*;
mod animated_show;
@ -188,11 +195,18 @@ mod for_loop;
mod show;
pub use animated_show::*;
pub use for_loop::*;
#[cfg(feature = "experimental-islands")]
pub use serde;
#[cfg(feature = "experimental-islands")]
pub use serde_json;
pub use show::*;
pub use suspense_component::*;
mod suspense_component;
mod text_prop;
mod transition;
// used by the component macro to generate islands
#[doc(hidden)]
pub use const_format;
pub use text_prop::TextProp;
#[cfg(any(debug_assertions, feature = "ssr"))]
#[doc(hidden)]
@ -204,6 +218,23 @@ pub use typed_builder;
pub use typed_builder::Optional;
#[doc(hidden)]
pub use typed_builder_macro;
#[doc(hidden)]
#[cfg(any(
feature = "csr",
feature = "hydrate",
feature = "template_macro"
))]
pub use wasm_bindgen; // used in islands
#[doc(hidden)]
#[cfg(any(
feature = "csr",
feature = "hydrate",
feature = "template_macro"
))]
pub use web_sys; // used in islands
// used by the component macro to generate islands
#[doc(hidden)]
pub use xxhash_rust;
mod children;
pub use children::*;
extern crate self as leptos;

View file

@ -6,7 +6,6 @@ fn simple_ssr_test() {
let runtime = create_runtime();
let (value, set_value) = create_signal(0);
let rendered = view! {
<div>
<button on:click=move |_| set_value.update(|value| *value -= 1)>"-1"</button>
<span>"Value: " {move || value.get().to_string()} "!"</span>
@ -14,13 +13,22 @@ fn simple_ssr_test() {
</div>
};
assert!(rendered.into_view().render_to_string().contains(
"<div id=\"_0-0-1\"><button id=\"_0-0-2\">-1</button><span \
id=\"_0-0-3\">Value: \
<!--hk=_0-0-4o|leptos-dyn-child-start-->0<!\
--hk=_0-0-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-0-5\">+1</button></div>"
));
if cfg!(all(feature = "experimental-islands", feature = "ssr")) {
assert_eq!(
rendered.into_view().render_to_string(),
"<div><button>-1</button><span>Value: \
0!</span><button>+1</button></div>"
);
} else {
assert!(rendered.into_view().render_to_string().contains(
"<div data-hk=\"0-0-1\"><button \
data-hk=\"0-0-2\">-1</button><span data-hk=\"0-0-3\">Value: \
<!--hk=0-0-4o|leptos-dyn-child-start-->0<!\
--hk=0-0-4c|leptos-dyn-child-end-->!</span><button \
data-hk=\"0-0-5\">+1</button></div>"
));
}
runtime.dispose();
}
@ -51,13 +59,22 @@ fn ssr_test_with_components() {
</div>
};
assert!(rendered.into_view().render_to_string().contains(
"<div id=\"_0-0-3\"><button id=\"_0-0-4\">-1</button><span \
id=\"_0-0-5\">Value: \
<!--hk=_0-0-6o|leptos-dyn-child-start-->1<!\
--hk=_0-0-6c|leptos-dyn-child-end-->!</span><button \
id=\"_0-0-7\">+1</button></div>"
));
if cfg!(all(feature = "experimental-islands", feature = "ssr")) {
assert_eq!(
rendered.into_view().render_to_string(),
"<div class=\"counters\"><div><button>-1</button><span>Value: \
1!</span><button>+1</button></div><div><button>-1</\
button><span>Value: 2!</span><button>+1</button></div></div>"
);
} else {
assert!(rendered.into_view().render_to_string().contains(
"<div data-hk=\"0-0-3\"><button \
data-hk=\"0-0-4\">-1</button><span data-hk=\"0-0-5\">Value: \
<!--hk=0-0-6o|leptos-dyn-child-start-->1<!\
--hk=0-0-6c|leptos-dyn-child-end-->!</span><button \
data-hk=\"0-0-7\">+1</button></div>"
));
}
runtime.dispose();
}
@ -88,13 +105,22 @@ fn ssr_test_with_snake_case_components() {
</div>
};
assert!(rendered.into_view().render_to_string().contains(
"<div id=\"_0-0-3\"><button id=\"_0-0-4\">-1</button><span \
id=\"_0-0-5\">Value: \
<!--hk=_0-0-6o|leptos-dyn-child-start-->1<!\
--hk=_0-0-6c|leptos-dyn-child-end-->!</span><button \
id=\"_0-0-7\">+1</button></div>"
));
if cfg!(all(feature = "experimental-islands", feature = "ssr")) {
assert_eq!(
rendered.into_view().render_to_string(),
"<div class=\"counters\"><div><button>-1</button><span>Value: \
1!</span><button>+1</button></div><div><button>-1</\
button><span>Value: 2!</span><button>+1</button></div></div>"
);
} else {
assert!(rendered.into_view().render_to_string().contains(
"<div data-hk=\"0-0-3\"><button \
data-hk=\"0-0-4\">-1</button><span data-hk=\"0-0-5\">Value: \
<!--hk=0-0-6o|leptos-dyn-child-start-->1<!\
--hk=0-0-6c|leptos-dyn-child-end-->!</span><button \
data-hk=\"0-0-7\">+1</button></div>"
));
}
runtime.dispose();
}
@ -111,10 +137,16 @@ fn test_classes() {
<div class="my big" class:a={move || value.get() > 10} class:red=true class:car={move || value.get() > 1}></div>
};
assert!(rendered
.into_view()
.render_to_string()
.contains("<div id=\"_0-0-1\" class=\"my big red car\"></div>"));
if cfg!(all(feature = "experimental-islands", feature = "ssr")) {
assert_eq!(
rendered.into_view().render_to_string(),
"<div class=\"my big red car\"></div>"
);
} else {
assert!(rendered.into_view().render_to_string().contains(
"<div data-hk=\"0-0-1\" class=\"my big red car\"></div>"
));
}
runtime.dispose();
}
@ -133,10 +165,18 @@ fn ssr_with_styles() {
</div>
};
assert!(rendered.into_view().render_to_string().contains(
"<div id=\"_0-0-1\" class=\" myclass\"><button id=\"_0-0-2\" \
class=\"btn myclass\">-1</button></div>"
));
if cfg!(all(feature = "experimental-islands", feature = "ssr")) {
assert_eq!(
rendered.into_view().render_to_string(),
"<div class=\" myclass\"><button class=\"btn \
myclass\">-1</button></div>"
);
} else {
assert!(rendered.into_view().render_to_string().contains(
"<div data-hk=\"0-0-1\" class=\" myclass\"><button \
data-hk=\"0-0-2\" class=\"btn myclass\">-1</button></div>"
));
}
runtime.dispose();
}
@ -152,10 +192,17 @@ fn ssr_option() {
<option/>
};
assert!(rendered
.into_view()
.render_to_string()
.contains("<option id=\"_0-0-1\"></option>"));
if cfg!(all(feature = "experimental-islands", feature = "ssr")) {
assert_eq!(
rendered.into_view().render_to_string(),
"<option></option>"
);
} else {
assert!(rendered
.into_view()
.render_to_string()
.contains("<option data-hk=\"0-0-1\"></option>"));
}
runtime.dispose();
}

View file

@ -67,6 +67,7 @@ features = [
"CustomEvent",
"DeviceMotionEvent",
"DeviceOrientationEvent",
"DomStringMap",
"DragEvent",
"ErrorEvent",
"Event",
@ -170,6 +171,7 @@ hydrate = ["leptos_reactive/hydrate", "web"]
ssr = ["leptos_reactive/ssr"]
nightly = ["leptos_reactive/nightly"]
nonce = ["dep:base64", "dep:getrandom", "dep:rand"]
experimental-islands = ["leptos_reactive/experimental-islands"]
[package.metadata.cargo-all-features]
denylist = ["nightly"]

View file

@ -66,7 +66,7 @@ pub struct ComponentRepr {
pub children: Vec<View>,
closing: Comment,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
pub(crate) id: Option<HydrationKey>,
#[cfg(debug_assertions)]
pub(crate) view_marker: Option<String>,
}
@ -175,12 +175,15 @@ impl ComponentRepr {
#[inline(always)]
pub fn new_with_id(
name: impl Into<Oco<'static, str>>,
id: HydrationKey,
id: Option<HydrationKey>,
) -> Self {
Self::new_with_id_concrete(name.into(), id)
}
fn new_with_id_concrete(name: Oco<'static, str>, id: HydrationKey) -> Self {
fn new_with_id_concrete(
name: Oco<'static, str>,
id: Option<HydrationKey>,
) -> Self {
let markers = (
Comment::new(format!("</{name}>"), &id, true),
#[cfg(debug_assertions)]
@ -239,7 +242,7 @@ where
F: FnOnce() -> V,
V: IntoView,
{
id: HydrationKey,
id: Option<HydrationKey>,
name: Oco<'static, str>,
children_fn: F,
}

View file

@ -22,7 +22,7 @@ pub struct DynChildRepr {
pub(crate) child: Rc<RefCell<Box<Option<View>>>>,
closing: Comment,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
pub(crate) id: Option<HydrationKey>,
}
impl fmt::Debug for DynChildRepr {
@ -81,7 +81,7 @@ impl Mountable for DynChildRepr {
}
impl DynChildRepr {
fn new_with_id(id: HydrationKey) -> Self {
fn new_with_id(id: Option<HydrationKey>) -> Self {
let markers = (
Comment::new("</DynChild>", &id, true),
#[cfg(debug_assertions)]
@ -126,7 +126,7 @@ where
CF: Fn() -> N + 'static,
N: IntoView,
{
id: crate::HydrationKey,
id: Option<HydrationKey>,
child_fn: CF,
}
@ -146,7 +146,7 @@ where
#[doc(hidden)]
#[track_caller]
#[inline(always)]
pub const fn new_with_id(id: HydrationKey, child_fn: CF) -> Self {
pub const fn new_with_id(id: Option<HydrationKey>, child_fn: CF) -> Self {
Self { id, child_fn }
}
}

View file

@ -55,7 +55,7 @@ pub struct EachRepr {
pub(crate) children: Rc<RefCell<Vec<Option<EachItem>>>>,
closing: Comment,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
pub(crate) id: Option<HydrationKey>,
}
impl fmt::Debug for EachRepr {
@ -175,7 +175,7 @@ pub(crate) struct EachItem {
pub(crate) child: View,
closing: Option<Comment>,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
pub(crate) id: Option<HydrationKey>,
}
impl fmt::Debug for EachItem {

View file

@ -70,7 +70,12 @@ where
E: Into<Error>,
{
fn into_view(self) -> crate::View {
let id = ErrorKey(HydrationCtx::peek().to_string().into());
let id = ErrorKey(
HydrationCtx::peek()
.map(|n| n.to_string())
.unwrap_or_default()
.into(),
);
let errors = use_context::<RwSignal<Errors>>();
match self {
Ok(stuff) => {

View file

@ -25,7 +25,7 @@ where
/// Represents a group of [`views`](View).
#[derive(Debug, Clone)]
pub struct Fragment {
id: HydrationKey,
id: Option<HydrationKey>,
/// The nodes contained in the fragment.
pub nodes: Vec<View>,
#[cfg(debug_assertions)]
@ -74,7 +74,10 @@ impl Fragment {
/// Creates a new [`Fragment`] with the given hydration ID from a [`Vec<Node>`].
#[inline(always)]
pub const fn new_with_id(id: HydrationKey, nodes: Vec<View>) -> Self {
pub const fn new_with_id(
id: Option<HydrationKey>,
nodes: Vec<View>,
) -> Self {
Self {
id,
nodes,
@ -91,7 +94,7 @@ impl Fragment {
/// Returns the fragment's hydration ID.
#[inline(always)]
pub fn id(&self) -> &HydrationKey {
pub fn id(&self) -> &Option<HydrationKey> {
&self.id
}

View file

@ -17,7 +17,7 @@ use crate::{hydration::HydrationCtx, Comment, CoreComponent, IntoView, View};
pub struct UnitRepr {
comment: Comment,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
pub(crate) id: Option<HydrationKey>,
}
impl fmt::Debug for UnitRepr {

View file

@ -107,16 +107,16 @@ impl<E: FromWasmAbi> Custom<E> {
/// # use leptos::*;
/// # let runtime = create_runtime();
/// # let canvas_ref: NodeRef<html::Canvas> = create_node_ref();
/// let mut non_passive_wheel = ev::Custom::<ev::WheelEvent>::new("wheel");
/// # if false {
/// let mut non_passive_wheel = ev::Custom::<ev::WheelEvent>::new("wheel");
/// let options = non_passive_wheel.options_mut();
/// options.passive(false);
/// # }
/// canvas_ref.on_load(move |canvas: HtmlElement<html::Canvas>| {
/// canvas.on(non_passive_wheel, move |_event| {
/// // Handle _event
/// });
/// });
/// # }
/// # runtime.dispose();
/// ```
///

View file

@ -6,7 +6,7 @@ cfg_if! {
if #[cfg(all(target_arch = "wasm32", feature = "web"))] {
use crate::events::*;
use crate::macro_helpers::*;
use crate::{mount_child, MountKind};
use crate::{mount_child, HydrationKey, MountKind};
use once_cell::unsync::Lazy as LazyCell;
use std::cell::Cell;
use wasm_bindgen::JsCast;
@ -85,7 +85,7 @@ pub trait ElementDescriptor: ElementDescriptorBounds {
/// A unique `id` that should be generated for each new instance of
/// this element, and be consistent for both SSR and CSR.
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
fn hydration_id(&self) -> &HydrationKey;
fn hydration_id(&self) -> &Option<HydrationKey>;
}
/// Trait for converting any type which impl [`AsRef<web_sys::Element>`]
@ -134,7 +134,7 @@ pub struct AnyElement {
pub(crate) element: web_sys::HtmlElement,
pub(crate) is_void: bool,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
pub(crate) id: Option<HydrationKey>,
}
impl std::ops::Deref for AnyElement {
@ -173,7 +173,7 @@ impl ElementDescriptor for AnyElement {
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
#[inline(always)]
fn hydration_id(&self) -> &HydrationKey {
fn hydration_id(&self) -> &Option<HydrationKey> {
&self.id
}
}
@ -185,7 +185,7 @@ pub struct Custom {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
element: web_sys::HtmlElement,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
id: HydrationKey,
id: Option<HydrationKey>,
}
impl Custom {
@ -195,10 +195,10 @@ impl Custom {
let id = HydrationCtx::id();
#[cfg(all(target_arch = "wasm32", feature = "web"))]
let element = if HydrationCtx::is_hydrating() {
if let Some(el) =
crate::document().get_element_by_id(&format!("_{id}"))
{
let element = if HydrationCtx::is_hydrating() && id.is_some() {
let id = id.unwrap();
#[cfg(feature = "hydrate")]
if let Some(el) = crate::hydration::get_element(&id.to_string()) {
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
@ -208,34 +208,21 @@ impl Custom {
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("id").unwrap();
el.unchecked_into()
} else if let Ok(Some(el)) =
crate::document().query_selector(&format!("[leptos-hk=_{id}]"))
{
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
name.to_ascii_uppercase(),
"SSR and CSR elements have the same hydration key but \
different node kinds. Check out the docs for information \
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("leptos-hk").unwrap();
//el.remove_attribute(wasm_bindgen::intern("id")).unwrap();
el.unchecked_into()
} else {
if !is_meta_tag() {
crate::warn!(
"element with id {id} not found, ignoring it for \
hydration"
hydration",
);
}
crate::document().create_element(&name).unwrap()
}
#[cfg(not(feature = "hydrate"))]
unreachable!()
} else {
crate::document().create_element(&name).unwrap()
};
@ -275,7 +262,7 @@ impl ElementDescriptor for Custom {
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
#[inline(always)]
fn hydration_id(&self) -> &HydrationKey {
fn hydration_id(&self) -> &Option<HydrationKey> {
&self.id
}
}
@ -1082,10 +1069,8 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
let mut element = Element::new(element);
if attrs.iter_mut().any(|(name, _)| name == "id") {
attrs.push(("leptos-hk".into(), format!("_{id}").into()));
} else {
attrs.push(("id".into(), format!("_{id}").into()));
if let Some(id) = id {
attrs.push(("data-hk".into(), id.to_string().into()));
}
element.attrs = attrs;
@ -1153,7 +1138,7 @@ macro_rules! generate_html_tags {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
element: web_sys::HtmlElement,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
id: HydrationKey,
id: Option<HydrationKey>,
}
impl Default for [<$tag:camel $($trailing_)?>] {
@ -1218,7 +1203,7 @@ macro_rules! generate_html_tags {
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
#[inline(always)]
fn hydration_id(&self) -> &HydrationKey {
fn hydration_id(&self) -> &Option<HydrationKey> {
&self.id
}
@ -1255,7 +1240,7 @@ macro_rules! generate_html_tags {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
fn create_leptos_element(
tag: &str,
id: crate::HydrationKey,
id: Option<HydrationKey>,
clone_element: fn() -> web_sys::HtmlElement,
) -> web_sys::HtmlElement {
#[cfg(not(debug_assertions))]
@ -1263,9 +1248,10 @@ fn create_leptos_element(
_ = tag;
}
if HydrationCtx::is_hydrating() {
if let Some(el) = crate::document().get_element_by_id(&format!("_{id}"))
{
#[cfg(feature = "hydrate")]
if HydrationCtx::is_hydrating() && id.is_some() {
let id = id.unwrap();
if let Some(el) = crate::hydration::get_element(&id.to_string()) {
#[cfg(debug_assertions)]
assert_eq!(
&el.node_name().to_ascii_uppercase(),
@ -1275,23 +1261,6 @@ fn create_leptos_element(
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("id").unwrap();
el.unchecked_into()
} else if let Ok(Some(el)) =
crate::document().query_selector(&format!("[leptos-hk=_{id}]"))
{
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
tag,
"SSR and CSR elements have the same hydration key but \
different node kinds. Check out the docs for information \
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("leptos-hk").unwrap();
el.unchecked_into()
} else {
if !is_meta_tag() {
@ -1305,6 +1274,10 @@ fn create_leptos_element(
} else {
clone_element()
}
#[cfg(not(feature = "hydrate"))]
{
clone_element()
}
}
#[cfg(all(debug_assertions, target_arch = "wasm32", feature = "web"))]

View file

@ -1,17 +1,20 @@
#[cfg(all(
feature = "experimental-islands",
any(feature = "hydrate", feature = "ssr")
))]
use leptos_reactive::SharedContext;
use std::{cell::RefCell, fmt::Display};
#[cfg(all(target_arch = "wasm32", feature = "hydrate"))]
mod hydration {
#[cfg(feature = "hydrate")]
mod hydrate_only {
use once_cell::unsync::Lazy as LazyCell;
use std::{cell::RefCell, collections::HashMap};
use std::{cell::Cell, collections::HashMap};
use wasm_bindgen::JsCast;
/// See ["createTreeWalker"](https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker)
#[allow(unused)]
const FILTER_SHOW_COMMENT: u32 = 0b10000000;
// We can tell if we start in hydration mode by checking to see if the
// id "_0-1" is present in the DOM. If it is, we know we are hydrating from
// the server, if not, we are starting off in CSR
thread_local! {
pub static HYDRATION_COMMENTS: LazyCell<HashMap<String, web_sys::Comment>> = LazyCell::new(|| {
let document = crate::document();
@ -32,53 +35,50 @@ mod hydration {
map
});
#[cfg(debug_assertions)]
pub static VIEW_MARKERS: LazyCell<HashMap<String, web_sys::Comment>> = LazyCell::new(|| {
pub static HYDRATION_ELEMENTS: LazyCell<HashMap<String, web_sys::HtmlElement>> = LazyCell::new(|| {
let document = crate::document();
let body = document.body().unwrap();
let walker = document
.create_tree_walker_with_what_to_show(&body, FILTER_SHOW_COMMENT)
.unwrap();
let mut map = HashMap::new();
while let Ok(Some(node)) = walker.next_node() {
if let Some(content) = node.text_content() {
if let Some(id) = content.strip_prefix("leptos-view|") {
map.insert(id.into(), node.unchecked_into());
let els = document.query_selector_all("[data-hk]");
if let Ok(list) = els {
let len = list.length();
let mut map = HashMap::with_capacity(len as usize);
for idx in 0..len {
let el = list.item(idx).unwrap().unchecked_into::<web_sys::HtmlElement>();
let dataset = el.dataset();
let hk = dataset.get(wasm_bindgen::intern("hk")).unwrap();
map.insert(hk, el);
}
}
map
} else {
Default::default()
}
map
});
pub static IS_HYDRATING: RefCell<LazyCell<bool>> = RefCell::new(LazyCell::new(|| {
#[cfg(debug_assertions)]
return crate::document().get_element_by_id("_0-0-1").is_some()
|| crate::document().get_element_by_id("_0-0-1o").is_some()
|| HYDRATION_COMMENTS.with(|comments| comments.get("_0-0-1o").is_some());
#[cfg(not(debug_assertions))]
return crate::document().get_element_by_id("_0-0-1").is_some()
|| HYDRATION_COMMENTS.with(|comments| comments.get("_0-0-1").is_some());
}));
pub static IS_HYDRATING: Cell<bool> = Cell::new(true);
}
#[allow(unused)]
pub fn get_marker(id: &str) -> Option<web_sys::Comment> {
HYDRATION_COMMENTS.with(|comments| comments.get(id).cloned())
}
#[allow(unused)]
pub fn get_element(hk: &str) -> Option<web_sys::HtmlElement> {
HYDRATION_ELEMENTS.with(|els| els.get(hk).cloned())
}
}
#[cfg(all(target_arch = "wasm32", feature = "hydrate"))]
pub(crate) use hydration::*;
#[cfg(feature = "hydrate")]
pub(crate) use hydrate_only::*;
/// A stable identifier within the server-rendering or hydration process.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct HydrationKey {
/// ID of the current key.
pub id: usize,
/// ID of the current error boundary.
pub error: usize,
/// ID of the current fragment.
pub fragment: usize,
/// ID of the current error boundary.
pub error: usize,
/// ID of the current key.
pub id: usize,
}
impl Display for HydrationKey {
@ -87,24 +87,94 @@ impl Display for HydrationKey {
}
}
thread_local!(static ID: RefCell<HydrationKey> = Default::default());
impl std::str::FromStr for HydrationKey {
type Err = (); // TODO better error
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut pieces = s.splitn(3, '-');
let first = pieces.next().ok_or(())?;
let second = pieces.next().ok_or(())?;
let third = pieces.next().ok_or(())?;
let fragment = usize::from_str(first).map_err(|_| ())?;
let error = usize::from_str(second).map_err(|_| ())?;
let id = usize::from_str(third).map_err(|_| ())?;
Ok(HydrationKey {
fragment,
error,
id,
})
}
}
#[cfg(test)]
mod tests {
#[test]
fn parse_hydration_key() {
use crate::HydrationKey;
use std::str::FromStr;
assert_eq!(
HydrationKey::from_str("1-2-3"),
Ok(HydrationKey {
fragment: 1,
error: 2,
id: 3
})
)
}
}
thread_local!(static ID: RefCell<HydrationKey> = RefCell::new(HydrationKey { fragment: 0, error: 0, id: 0 }));
/// Control and utility methods for hydration.
pub struct HydrationCtx;
impl HydrationCtx {
/// If you're in an hydration context, get the next `id` without incrementing it.
pub fn peek() -> Option<HydrationKey> {
#[cfg(all(
feature = "experimental-islands",
any(feature = "hydrate", feature = "ssr")
))]
let no_hydrate = SharedContext::no_hydrate();
#[cfg(not(all(
feature = "experimental-islands",
any(feature = "hydrate", feature = "ssr")
)))]
let no_hydrate = false;
if no_hydrate {
None
} else {
Some(ID.with(|id| *id.borrow()))
}
}
/// Get the next `id` without incrementing it.
pub fn peek() -> HydrationKey {
pub fn peek_always() -> HydrationKey {
ID.with(|id| *id.borrow())
}
/// Increments the current hydration `id` and returns it
pub fn id() -> HydrationKey {
ID.with(|id| {
let mut id = id.borrow_mut();
id.id = id.id.wrapping_add(1);
*id
})
pub fn id() -> Option<HydrationKey> {
#[cfg(all(
feature = "experimental-islands",
any(feature = "hydrate", feature = "ssr")
))]
let no_hydrate = SharedContext::no_hydrate();
#[cfg(not(all(
feature = "experimental-islands",
any(feature = "hydrate", feature = "ssr")
)))]
let no_hydrate = false;
if no_hydrate {
None
} else {
Some(ID.with(|id| {
let mut id = id.borrow_mut();
id.id = id.id.wrapping_add(1);
*id
}))
}
}
/// Resets the hydration `id` for the next component, and returns it
@ -130,7 +200,13 @@ impl HydrationCtx {
#[doc(hidden)]
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub fn reset_id() {
ID.with(|id| *id.borrow_mut() = Default::default());
ID.with(|id| {
*id.borrow_mut() = HydrationKey {
fragment: 0,
error: 0,
id: 0,
}
});
}
/// Resumes hydration from the provided `id`. Useful for
@ -139,38 +215,63 @@ impl HydrationCtx {
ID.with(|i| *i.borrow_mut() = id);
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
pub(crate) fn stop_hydrating() {
/// Resumes hydration after the provided `id`. Useful for
/// islands and other fancy things.
pub fn continue_after(id: HydrationKey) {
ID.with(|i| {
*i.borrow_mut() = HydrationKey {
fragment: id.fragment,
error: id.error,
id: id.id + 1,
}
});
}
#[doc(hidden)]
pub fn stop_hydrating() {
#[cfg(feature = "hydrate")]
{
IS_HYDRATING.with(|is_hydrating| {
std::mem::take(&mut *is_hydrating.borrow_mut());
is_hydrating.set(false);
})
}
}
#[doc(hidden)]
#[cfg(feature = "hydrate")]
pub fn with_hydration_on<T>(f: impl FnOnce() -> T) -> T {
let prev = IS_HYDRATING.with(|is_hydrating| {
let prev = is_hydrating.get();
is_hydrating.set(true);
prev
});
let value = f();
IS_HYDRATING.with(|is_hydrating| is_hydrating.set(prev));
value
}
/// Whether the UI is currently in the process of hydrating from the server-sent HTML.
#[inline(always)]
pub fn is_hydrating() -> bool {
#[cfg(all(target_arch = "wasm32", feature = "hydrate"))]
#[cfg(feature = "hydrate")]
{
IS_HYDRATING.with(|is_hydrating| **is_hydrating.borrow())
IS_HYDRATING.with(|is_hydrating| is_hydrating.get())
}
#[cfg(not(all(target_arch = "wasm32", feature = "hydrate")))]
#[cfg(not(feature = "hydrate"))]
{
false
}
}
#[allow(dead_code)] // not used in CSR
#[cfg(feature = "hydrate")]
#[allow(unused)]
pub(crate) fn to_string(id: &HydrationKey, closing: bool) -> String {
#[cfg(debug_assertions)]
return format!("_{id}{}", if closing { 'c' } else { 'o' });
return format!("{id}{}", if closing { 'c' } else { 'o' });
#[cfg(not(debug_assertions))]
{
let _ = closing;
format!("_{id}")
id.to_string()
}
}
}

View file

@ -268,7 +268,7 @@ cfg_if! {
is_void: bool,
attrs: SmallVec<[(Oco<'static, str>, Oco<'static, str>); 4]>,
children: ElementChildren,
id: HydrationKey,
id: Option<HydrationKey>,
#[cfg(debug_assertions)]
/// Optional marker for the view macro source, in debug mode.
pub view_marker: Option<String>
@ -406,7 +406,7 @@ impl Comment {
#[inline]
fn new(
content: impl Into<Oco<'static, str>>,
id: &HydrationKey,
id: &Option<HydrationKey>,
closing: bool,
) -> Self {
Self::new_inner(content.into(), id, closing)
@ -414,7 +414,7 @@ impl Comment {
fn new_inner(
content: Oco<'static, str>,
id: &HydrationKey,
id: &Option<HydrationKey>,
closing: bool,
) -> Self {
cfg_if! {
@ -436,7 +436,8 @@ impl Comment {
node.set_text_content(Some(&format!(" {content} ")));
#[cfg(feature = "hydrate")]
if HydrationCtx::is_hydrating() {
if HydrationCtx::is_hydrating() && id.is_some() {
let id = id.as_ref().unwrap();
let id = HydrationCtx::to_string(id, closing);
if let Some(marker) = hydration::get_marker(&id) {
@ -467,7 +468,7 @@ pub struct Text {
/// to update the node without recreating it, we need to be able
/// to possibly reuse a previous node.
#[cfg(all(target_arch = "wasm32", feature = "web"))]
node: web_sys::Node,
pub(crate) node: web_sys::Node,
/// The current contents of the text node.
pub content: Oco<'static, str>,
}
@ -872,18 +873,35 @@ where
/// Runs the provided closure and mounts the result to the provided element.
pub fn mount_to<F, N>(parent: web_sys::HtmlElement, f: F)
where
F: Fn() -> N + 'static,
F: FnOnce() -> N + 'static,
N: IntoView,
{
mount_to_with_stop_hydrating(parent, true, f)
}
/// Runs the provided closure and mounts the result to the provided element.
pub fn mount_to_with_stop_hydrating<F, N>(
parent: web_sys::HtmlElement,
stop_hydrating: bool,
f: F,
) where
F: FnOnce() -> N + 'static,
N: IntoView,
{
cfg_if! {
if #[cfg(all(target_arch = "wasm32", feature = "web"))] {
let node = f().into_view();
HydrationCtx::stop_hydrating();
parent.append_child(&node.get_mountable_node()).unwrap();
if stop_hydrating {
HydrationCtx::stop_hydrating();
}
// TODO is this *ever* needed? unnecessarily remounts hydrated child
// parent.append_child(&node.get_mountable_node()).unwrap();
_ = parent;
std::mem::forget(node);
} else {
_ = parent;
_ = f;
_ = stop_hydrating;
crate::warn!("`mount_to` should not be called outside the browser.");
}
}

View file

@ -50,18 +50,17 @@ macro_rules! generate_math_tags {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
element: web_sys::HtmlElement,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
id: HydrationKey,
id: Option<HydrationKey>,
}
impl Default for [<$tag:camel $($second:camel $($third:camel)?)?>] {
fn default() -> Self {
let id = HydrationCtx::id();
#[cfg(all(target_arch = "wasm32", feature = "web"))]
let element = if HydrationCtx::is_hydrating() {
if let Some(el) = crate::document().get_element_by_id(
&format!("_{id}")
) {
#[cfg(all(target_arch = "wasm32", feature = "hydrate"))]
let element = if HydrationCtx::is_hydrating() && id.is_some() {
let id = id.unwrap();
if let Some(el) = crate::hydration::get_element(&id.to_string()) {
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
@ -71,23 +70,6 @@ macro_rules! generate_math_tags {
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("id").unwrap();
el.unchecked_into()
} else if let Ok(Some(el)) = crate::document().query_selector(
&format!("[leptos-hk=_{id}]")
) {
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
stringify!([<$tag:upper $(_ $second:upper $(_ $third:upper)?)?>]),
"SSR and CSR elements have the same hydration key but \
different node kinds. Check out the docs for information \
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("leptos-hk").unwrap();
el.unchecked_into()
} else {
crate::warn!(
@ -110,6 +92,14 @@ macro_rules! generate_math_tags {
)
};
#[cfg(all(target_arch = "wasm32", feature = "web", not(feature = "hydrate")))]
let element = [<$tag:upper $(_ $second:upper $(_ $third:upper)?)?>]
.with(|el|
el.clone_node()
.unwrap()
.unchecked_into()
);
Self {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
element,
@ -150,7 +140,7 @@ macro_rules! generate_math_tags {
}
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
fn hydration_id(&self) -> &HydrationKey {
fn hydration_id(&self) -> &Option<HydrationKey> {
&self.id
}

View file

@ -4,7 +4,7 @@
use crate::{
html::{ElementChildren, StringOrView},
CoreComponent, HydrationCtx, IntoView, View,
CoreComponent, HydrationCtx, HydrationKey, IntoView, View,
};
use cfg_if::cfg_if;
use futures::{stream::FuturesUnordered, Future, Stream, StreamExt};
@ -401,11 +401,11 @@ impl View {
};
cfg_if! {
if #[cfg(debug_assertions)] {
let content = format!(r#"<!--hk={}|leptos-{name}-start-->{}<!--hk={}|leptos-{name}-end-->"#,
HydrationCtx::to_string(&node.id, false),
let name = to_kebab_case(&node.name);
let content = format!(r#"{}{}{}"#,
node.id.to_marker(false, &name),
content(),
HydrationCtx::to_string(&node.id, true),
name = to_kebab_case(&node.name)
node.id.to_marker(true, &name),
);
if let Some(id) = node.view_marker {
format!("<!--leptos-view|{id}|open-->{content}<!--leptos-view|{id}|close-->").into()
@ -414,9 +414,9 @@ impl View {
}
} else {
format!(
r#"{}<!--hk={}-->"#,
r#"{}{}"#,
content(),
HydrationCtx::to_string(&node.id, true)
node.id.to_marker(true)
).into()
}
}
@ -434,21 +434,11 @@ impl View {
"",
false,
Box::new(move || {
#[cfg(debug_assertions)]
{
format!(
"<!--hk={}|leptos-unit-->",
HydrationCtx::to_string(&u.id, true)
)
.into()
}
#[cfg(not(debug_assertions))]
format!(
"<!--hk={}-->",
HydrationCtx::to_string(&u.id, true)
u.id.to_marker(
true,
#[cfg(debug_assertions)]
"unit",
)
.into()
})
as Box<dyn FnOnce() -> Oco<'static, str>>,
),
@ -515,7 +505,10 @@ impl View {
.flatten()
.map(|node| {
let id = node.id;
let is_el = matches!(node.child, View::Element(_));
let is_el = matches!(
node.child,
View::Element(_)
);
let content = || {
node.child.render_to_string_helper(
@ -523,32 +516,24 @@ impl View {
)
};
#[cfg(debug_assertions)]
{
if is_el {
content()
} else {
format!(
"<!--hk={}|leptos-each-item-start-->{}<!\
--hk={}|leptos-each-item-end-->",
HydrationCtx::to_string(&id, false),
content(),
HydrationCtx::to_string(&id, true),
).into()
}
}
#[cfg(not(debug_assertions))]
{
if is_el {
content()
} else {
format!(
"{}<!--hk={}-->",
content(),
HydrationCtx::to_string(&id, true)
).into()
}
if is_el {
content()
} else {
format!(
"{}{}{}",
id.to_marker(
false,
#[cfg(debug_assertions)]
"each-item",
),
content(),
id.to_marker(
true,
#[cfg(debug_assertions)]
"each-item",
)
)
.into()
}
})
.join("")
@ -560,24 +545,21 @@ impl View {
};
if wrap {
cfg_if! {
if #[cfg(debug_assertions)] {
format!(
r#"<!--hk={}|leptos-{name}-start-->{}<!--hk={}|leptos-{name}-end-->"#,
HydrationCtx::to_string(&id, false),
content(),
HydrationCtx::to_string(&id, true),
).into()
} else {
let _ = name;
format!(
r#"{}<!--hk={}-->"#,
content(),
HydrationCtx::to_string(&id, true)
).into()
}
}
format!(
r#"{}{}{}"#,
id.to_marker(
false,
#[cfg(debug_assertions)]
name,
),
content(),
id.to_marker(
true,
#[cfg(debug_assertions)]
name,
),
)
.into()
} else {
content()
}
@ -735,3 +717,58 @@ where
{
html_escape::encode_double_quoted_attribute(value).into()
}
pub(crate) trait ToMarker {
fn to_marker(
&self,
closing: bool,
#[cfg(debug_assertions)] component_name: &str,
) -> Oco<'static, str>;
}
impl ToMarker for HydrationKey {
#[inline(always)]
fn to_marker(
&self,
closing: bool,
#[cfg(debug_assertions)] component_name: &str,
) -> Oco<'static, str> {
#[cfg(debug_assertions)]
{
if component_name == "unit" {
format!("<!--hk={self}|leptos-unit-->").into()
} else if closing {
format!("<!--hk={self}c|leptos-{component_name}-end-->").into()
} else {
format!("<!--hk={self}o|leptos-{component_name}-start-->")
.into()
}
}
#[cfg(not(debug_assertions))]
{
if closing {
format!("<!--hk={self}-->").into()
} else {
"".into()
}
}
}
}
impl ToMarker for Option<HydrationKey> {
#[inline(always)]
fn to_marker(
&self,
closing: bool,
#[cfg(debug_assertions)] component_name: &str,
) -> Oco<'static, str> {
self.map(|key| {
key.to_marker(
closing,
#[cfg(debug_assertions)]
component_name,
)
})
.unwrap_or("".into())
}
}

View file

@ -4,17 +4,16 @@
use crate::{
html::{ElementChildren, StringOrView},
ssr::render_serializers,
ssr::{render_serializers, ToMarker},
CoreComponent, HydrationCtx, View,
};
use async_recursion::async_recursion;
use cfg_if::cfg_if;
use futures::{channel::mpsc::UnboundedSender, Stream, StreamExt};
use itertools::Itertools;
use leptos_reactive::{
create_runtime, suspense::StreamChunk, Oco, RuntimeId, SharedContext,
};
use std::{borrow::Cow, collections::VecDeque};
use std::collections::VecDeque;
/// Renders a view to HTML, waiting to return until all `async` [Resource](leptos_reactive::Resource)s
/// loaded in `<Suspense/>` elements have finished loading.
@ -255,21 +254,25 @@ impl View {
chunks.push_back(StreamChunk::Sync(node.content))
}
View::Component(node) => {
cfg_if! {
if #[cfg(debug_assertions)] {
let name = crate::ssr::to_kebab_case(&node.name);
chunks.push_back(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-start-->"#, HydrationCtx::to_string(&node.id, false)).into()));
for child in node.children {
child.into_stream_chunks_helper(chunks, dont_escape_text);
}
chunks.push_back(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-end-->"#, HydrationCtx::to_string(&node.id, true)).into()));
} else {
for child in node.children {
child.into_stream_chunks_helper(chunks, dont_escape_text);
}
chunks.push_back(StreamChunk::Sync(format!(r#"<!--hk={}-->"#, HydrationCtx::to_string(&node.id, true)).into()))
}
#[cfg(debug_assertions)]
let name = crate::ssr::to_kebab_case(&node.name);
if cfg!(debug_assertions) {
chunks.push_back(StreamChunk::Sync(node.id.to_marker(
false,
#[cfg(debug_assertions)]
&name,
)));
}
for child in node.children {
child.into_stream_chunks_helper(chunks, dont_escape_text);
}
chunks.push_back(StreamChunk::Sync(node.id.to_marker(
true,
#[cfg(debug_assertions)]
&name,
)));
}
View::Element(el) => {
let is_script_or_style =
@ -303,7 +306,7 @@ impl View {
.attrs
.into_iter()
.filter_map(
|(name, value)| -> Option<Cow<'static, str>> {
|(name, value)| -> Option<Oco<'static, str>> {
if value.is_empty() {
Some(format!(" {name}").into())
} else if name == "inner_html" {
@ -375,24 +378,12 @@ impl View {
"",
false,
Box::new(move |chunks: &mut VecDeque<StreamChunk>| {
#[cfg(debug_assertions)]
{
chunks.push_back(StreamChunk::Sync(
format!(
"<!--hk={}|leptos-unit-->",
HydrationCtx::to_string(&u.id, true)
)
.into(),
));
}
#[cfg(not(debug_assertions))]
chunks.push_back(StreamChunk::Sync(
format!(
"<!--hk={}-->",
HydrationCtx::to_string(&u.id, true)
)
.into(),
u.id.to_marker(
true,
#[cfg(debug_assertions)]
"unit",
),
));
})
as Box<dyn FnOnce(&mut VecDeque<StreamChunk>)>,
@ -480,56 +471,31 @@ impl View {
);
#[cfg(debug_assertions)]
{
if !is_el {
chunks.push_back(
StreamChunk::Sync(
format!(
"<!--hk={}|leptos-each-item-start-->",
HydrationCtx::to_string(&id, false)
)
.into(),
),
);
}
node.child
.into_stream_chunks_helper(
chunks,
dont_escape_text,
);
if !is_el {
chunks.push_back(StreamChunk::Sync(
id.to_marker(
false,
"each-item",
),
))
};
node.child.into_stream_chunks_helper(
chunks,
dont_escape_text,
);
if !is_el {
chunks.push_back(
StreamChunk::Sync(
format!(
"<!--hk={}|leptos-each-item-end-->",
HydrationCtx::to_string(&id, true)
)
.into(),
if !is_el {
chunks.push_back(
StreamChunk::Sync(
id.to_marker(
true,
#[cfg(
debug_assertions
)]
"each-item",
),
);
}
}
#[cfg(not(debug_assertions))]
{
node.child
.into_stream_chunks_helper(
chunks,
dont_escape_text,
);
if !is_el {
chunks.push_back(
StreamChunk::Sync(
format!(
"<!--hk={}-->",
HydrationCtx::to_string(
&id, true
)
)
.into(),
),
);
}
),
);
}
}
},
@ -540,17 +506,18 @@ impl View {
};
if wrap {
cfg_if! {
if #[cfg(debug_assertions)] {
chunks.push_back(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-start-->", HydrationCtx::to_string(&id, false)).into()));
content(chunks);
chunks.push_back(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-end-->", HydrationCtx::to_string(&id, true)).into()));
} else {
let _ = name;
content(chunks);
chunks.push_back(StreamChunk::Sync(format!("<!--hk={}-->", HydrationCtx::to_string(&id, true)).into()))
}
#[cfg(debug_assertions)]
{
chunks.push_back(StreamChunk::Sync(
id.to_marker(false, name),
));
}
content(chunks);
chunks.push_back(StreamChunk::Sync(id.to_marker(
true,
#[cfg(debug_assertions)]
name,
)));
} else {
content(chunks);
}

View file

@ -47,18 +47,17 @@ macro_rules! generate_svg_tags {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
element: web_sys::HtmlElement,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
id: HydrationKey,
id: Option<HydrationKey>,
}
impl Default for [<$tag:camel $($second:camel $($third:camel)?)?>] {
fn default() -> Self {
let id = HydrationCtx::id();
#[cfg(all(target_arch = "wasm32", feature = "web"))]
let element = if HydrationCtx::is_hydrating() {
if let Some(el) = crate::document().get_element_by_id(
&format!("_{id}")
) {
#[cfg(all(target_arch = "wasm32", feature = "hydrate"))]
let element = if HydrationCtx::is_hydrating() && id.is_some() {
let id = id.unwrap();
if let Some(el) = crate::hydration::get_element(&id.to_string()) {
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
@ -67,24 +66,6 @@ macro_rules! generate_svg_tags {
different node kinds. Check out the docs for information \
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("id").unwrap();
el.unchecked_into()
} else if let Ok(Some(el)) = crate::document().query_selector(
&format!("[leptos-hk=_{id}]")
) {
#[cfg(debug_assertions)]
assert_eq!(
el.node_name().to_ascii_uppercase(),
stringify!([<$tag:upper $(_ $second:upper $(_ $third:upper)?)?>]),
"SSR and CSR elements have the same hydration key but \
different node kinds. Check out the docs for information \
about this kind of hydration bug: https://leptos-rs.github.io/leptos/ssr/24_hydration_bugs.html"
);
el.remove_attribute("leptos-hk").unwrap();
el.unchecked_into()
} else {
crate::warn!(
@ -107,6 +88,14 @@ macro_rules! generate_svg_tags {
)
};
#[cfg(all(target_arch = "wasm32", feature = "web", not(feature = "hydrate")))]
let element = [<$tag:upper $(_ $second:upper $(_ $third:upper)?)?>]
.with(|el|
el.clone_node()
.unwrap()
.unchecked_into()
);
Self {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
element,
@ -147,7 +136,7 @@ macro_rules! generate_svg_tags {
}
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
fn hydration_id(&self) -> &HydrationKey {
fn hydration_id(&self) -> &Option<HydrationKey> {
&self.id
}

View file

@ -41,6 +41,7 @@ hydrate = []
ssr = ["server_fn_macro/ssr"]
nightly = ["server_fn_macro/nightly"]
tracing = []
experimental-islands = []
[package.metadata.cargo-all-features]
denylist = ["nightly", "tracing"]

View file

@ -15,6 +15,7 @@ use syn::{
};
pub struct Model {
is_transparent: bool,
is_island: bool,
docs: Docs,
vis: Visibility,
name: Ident,
@ -65,6 +66,7 @@ impl Parse for Model {
Ok(Self {
is_transparent: false,
is_island: false,
docs,
vis: item.vis.clone(),
name: convert_from_snake_case(&item.sig.ident),
@ -104,6 +106,7 @@ impl ToTokens for Model {
fn to_tokens(&self, tokens: &mut TokenStream) {
let Self {
is_transparent,
is_island,
docs,
vis,
name,
@ -146,9 +149,28 @@ impl ToTokens for Model {
let props_name = format_ident!("{name}Props");
let props_builder_name = format_ident!("{name}PropsBuilder");
let props_serialized_name = format_ident!("{name}PropsSerialized");
let trace_name = format!("<{name} />");
let prop_builder_fields = prop_builder_fields(vis, props);
let is_island_with_children = *is_island
&& props.iter().any(|prop| prop.name.ident == "children");
let is_island_with_other_props = *is_island
&& ((is_island_with_children && props.len() > 1)
|| (!is_island_with_children && !props.is_empty()));
let prop_builder_fields =
prop_builder_fields(vis, props, is_island_with_other_props);
let props_serializer = if is_island_with_other_props {
let fields = prop_serializer_fields(vis, props);
quote! {
#[derive(::leptos::serde::Deserialize)]
#vis struct #props_serialized_name {
#fields
}
}
} else {
quote! {}
};
let prop_names = prop_names(props);
@ -192,25 +214,69 @@ impl ToTokens for Model {
(quote! {}, quote! {}, quote! {}, quote! {})
};
let component = if *is_transparent {
let component_id = name.to_string();
let hydrate_fn_name =
Ident::new(&format!("_island_{}", component_id), name.span());
let island_serialize_props = if is_island_with_other_props {
quote! {
let _leptos_ser_props = ::leptos::serde_json::to_string(&props).expect("couldn't serialize island props");
}
} else {
quote! {}
};
let island_serialized_props = if is_island_with_other_props {
quote! {
.attr("data-props", _leptos_ser_props)
}
} else {
quote! {}
};
let body_expr = if *is_island {
quote! {
::leptos::SharedContext::with_hydration(move || {
#body_name(#prop_names)
})
}
} else {
quote! {
#body_name(#prop_names)
}
};
let component = if *is_transparent {
body_expr
} else {
quote! {
::leptos::leptos_dom::Component::new(
stringify!(#name),
move || {
#tracing_guard_expr
#tracing_props_expr
#body_name(#prop_names)
#body_expr
}
)
}
};
// add island wrapper if island
let component = if *is_island {
quote! {
{
::leptos::leptos_dom::html::custom(
::leptos::leptos_dom::html::Custom::new("leptos-island"),
)
.attr("data-component", #component_id)
.attr("data-hkc", ::leptos::leptos_dom::HydrationCtx::peek_always().to_string())
#island_serialized_props
.child(#component)
}
}
} else {
component
};
let props_arg = if no_props {
quote! {}
} else {
@ -222,10 +288,29 @@ impl ToTokens for Model {
let destructure_props = if no_props {
quote! {}
} else {
let wrapped_children = if is_island_with_children
&& cfg!(feature = "ssr")
{
quote! {
let children = Box::new(|| ::leptos::Fragment::lazy(|| vec![
::leptos::SharedContext::with_hydration(move || {
::leptos::leptos_dom::html::custom(
::leptos::leptos_dom::html::Custom::new("leptos-children"),
)
.child(::leptos::SharedContext::no_hydration(children))
.into_view()
})
]));
}
} else {
quote! {}
};
quote! {
#island_serialize_props
let #props_name {
#prop_names
} = props;
#wrapped_children
}
};
@ -247,18 +332,128 @@ impl ToTokens for Model {
}
};
let body = quote! {
#body
#destructure_props
#tracing_span_expr
#component
};
let binding = if *is_island
&& cfg!(any(feature = "csr", feature = "hydrate"))
{
let island_props = if is_island_with_children
|| is_island_with_other_props
{
let (destructure, prop_builders) = if is_island_with_other_props
{
let prop_names = props
.iter()
.filter_map(|prop| {
if prop.name.ident == "children" {
None
} else {
let name = &prop.name.ident;
Some(quote! { #name, })
}
})
.collect::<TokenStream>();
let destructure = quote! {
let #props_serialized_name {
#prop_names
} = props;
};
let prop_builders = props
.iter()
.filter_map(|prop| {
if prop.name.ident == "children" {
None
} else {
let name = &prop.name.ident;
Some(quote! {
.#name(#name)
})
}
})
.collect::<TokenStream>();
(destructure, prop_builders)
} else {
(quote! {}, quote! {})
};
let children = if is_island_with_children {
quote! {
.children(Box::new(move || ::leptos::Fragment::lazy(|| vec![
::leptos::SharedContext::with_hydration(move || {
::leptos::leptos_dom::html::custom(
::leptos::leptos_dom::html::Custom::new("leptos-children"),
)
.into_view()
})])))
}
} else {
quote! {}
};
quote! {{
#destructure
#props_name::builder()
#prop_builders
#children
.build()
}}
} else {
quote! {}
};
let deserialize_island_props = if is_island_with_other_props {
quote! {
let props = el.dataset().get(::leptos::wasm_bindgen::intern("props"))
.and_then(|data| ::leptos::serde_json::from_str::<#props_serialized_name>(&data).ok())
.expect("could not deserialize props");
}
} else {
quote! {}
};
quote! {
#[::leptos::wasm_bindgen::prelude::wasm_bindgen]
#[allow(non_snake_case)]
pub fn #hydrate_fn_name(el: ::leptos::web_sys::HtmlElement) {
if let Some(Ok(key)) = el.dataset().get(::leptos::wasm_bindgen::intern("hkc")).map(|key| std::str::FromStr::from_str(&key)) {
::leptos::leptos_dom::HydrationCtx::continue_from(key);
}
#deserialize_island_props
::leptos::leptos_dom::mount_to_with_stop_hydrating(el, false, move || {
#name(#island_props)
})
}
}
} else {
quote! {}
};
let props_derive_serialize = if is_island_with_other_props {
quote! { , ::leptos::serde::Serialize }
} else {
quote! {}
};
let output = quote! {
#[doc = #builder_name_doc]
#[doc = ""]
#docs
#component_fn_prop_docs
#[derive(::leptos::typed_builder_macro::TypedBuilder)]
#[derive(::leptos::typed_builder_macro::TypedBuilder #props_derive_serialize)]
//#[builder(doc)]
#[builder(crate_module_path=::leptos::typed_builder)]
#vis struct #props_name #impl_generics #where_clause {
#prop_builder_fields
}
#props_serializer
#[allow(missing_docs)]
#binding
impl #impl_generics ::leptos::Props for #props_name #generics #where_clause {
type Builder = #props_builder_name #generics;
fn builder() -> Self::Builder {
@ -277,16 +472,7 @@ impl ToTokens for Model {
) #ret #(+ #lifetimes)*
#where_clause
{
#[allow(non_snake_case)]
// allowed for lifetimes that are needed for props struct
#[allow(clippy::needless_lifetimes)]
#body
#destructure_props
#tracing_span_expr
#component
}
};
@ -301,6 +487,13 @@ impl Model {
self
}
#[allow(clippy::wrong_self_convention)]
pub fn is_island(mut self) -> Self {
self.is_island = true;
self
}
}
struct Prop {
@ -526,6 +719,25 @@ impl TypedBuilderOpts {
}
}
impl TypedBuilderOpts {
fn to_serde_tokens(&self) -> TokenStream {
let default = if let Some(v) = &self.default_with_value {
let v = v.to_token_stream().to_string();
quote! { default=#v, }
} else if self.default {
quote! { default, }
} else {
quote! {}
};
if !default.is_empty() {
quote! { #[serde(#default)] }
} else {
quote! {}
}
}
}
impl ToTokens for TypedBuilderOpts {
fn to_tokens(&self, tokens: &mut TokenStream) {
let default = if let Some(v) = &self.default_with_value {
@ -565,7 +777,11 @@ impl ToTokens for TypedBuilderOpts {
}
}
fn prop_builder_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
fn prop_builder_fields(
vis: &Visibility,
props: &[Prop],
is_island_with_other_props: bool,
) -> TokenStream {
props
.iter()
.map(|prop| {
@ -587,6 +803,12 @@ fn prop_builder_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
} else {
quote!()
};
let skip_children_serde =
if is_island_with_other_props && name.ident == "children" {
quote!(#[serde(skip)])
} else {
quote!()
};
let PatIdent { ident, by_ref, .. } = &name;
@ -595,12 +817,43 @@ fn prop_builder_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
#builder_docs
#builder_attrs
#allow_missing_docs
#skip_children_serde
#vis #by_ref #ident: #ty,
}
})
.collect()
}
fn prop_serializer_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
props
.iter()
.filter_map(|prop| {
if prop.name.ident == "children" {
None
} else {
let Prop {
docs,
name,
prop_opts,
ty,
} = prop;
let builder_attrs =
TypedBuilderOpts::from_opts(prop_opts, is_option(ty));
let serde_attrs = builder_attrs.to_serde_tokens();
let PatIdent { ident, by_ref, .. } = &name;
Some(quote! {
#docs
#serde_attrs
#vis #by_ref #ident: #ty,
})
}
})
.collect()
}
fn prop_names(props: &[Prop]) -> TokenStream {
props
.iter()

View file

@ -632,6 +632,33 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
.into()
}
/// TODO docs for islands
#[proc_macro_error::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);
if transparent != "transparent" {
abort!(
transparent,
"only `transparent` is supported";
help = "try `#[island(transparent)]` or `#[island]`"
);
}
true
} else {
false
};
parse_macro_input!(s as component::Model)
.is_transparent(is_transparent)
.is_island()
.into_token_stream()
.into()
}
/// Annotates a struct so that it can be used with your Component as a `slot`.
///
/// The `#[slot]` macro allows you to annotate plain Rust struct as component slots and use them

View file

@ -297,19 +297,10 @@ fn element_to_tokens_ssr(
} else {
quote! { ::leptos::leptos_dom::HydrationCtx::id() }
};
match node
.attributes()
.iter()
.find(|node| matches!(node, NodeAttribute::Attribute(attr) if attr.key.to_string() == "id"))
{
Some(_) => {
template.push_str(" leptos-hk=\"_{}\"");
}
None => {
template.push_str(" id=\"_{}\"");
}
}
holes.push(hydration_id);
template.push_str("{}");
holes.push(quote! {
#hydration_id.map(|id| format!(" data-hk=\"{id}\"")).unwrap_or_default()
});
set_class_attribute_ssr(node, template, holes, global_class);
set_style_attribute_ssr(node, template, holes);

View file

@ -687,7 +687,7 @@ TokenStream [
},
Ident {
sym: click,
span: bytes(337..342),
span: bytes(357..362),
},
Punct {
char: ',',
@ -802,7 +802,7 @@ TokenStream [
},
Ident {
sym: click,
span: bytes(343..348),
span: bytes(383..388),
},
Punct {
char: ',',
@ -959,7 +959,7 @@ TokenStream [
},
Ident {
sym: click,
span: bytes(349..354),
span: bytes(429..434),
},
Punct {
char: ',',
@ -1208,7 +1208,7 @@ TokenStream [
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: "<div id=\"_{}\"><button id=\"_{}\">Clear</button><button id=\"_{}\">-1</button><span id=\"_{}\">Value: ",
lit: "<div{}><button{}>Clear</button><button{}>-1</button><span{}>Value: ",
},
Punct {
char: ',',
@ -1263,52 +1263,50 @@ TokenStream [
stream: TokenStream [],
},
Punct {
char: ',',
spacing: Alone,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
char: '.',
spacing: Alone,
},
Ident {
sym: leptos,
sym: map,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: id,
},
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: format,
},
Punct {
char: '!',
spacing: Alone,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: " data-hk=\"{id}\"",
span: bytes(337..356),
},
],
},
],
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
char: '.',
spacing: Alone,
},
Ident {
sym: leptos_dom,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: HydrationCtx,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: id,
sym: unwrap_or_default,
},
Group {
delimiter: Parenthesis,
@ -1366,6 +1364,56 @@ TokenStream [
delimiter: Parenthesis,
stream: TokenStream [],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: map,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: id,
},
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: format,
},
Punct {
char: '!',
spacing: Alone,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: " data-hk=\"{id}\"",
span: bytes(363..382),
},
],
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: unwrap_or_default,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
Punct {
char: ',',
spacing: Alone,
@ -1418,6 +1466,158 @@ TokenStream [
delimiter: Parenthesis,
stream: TokenStream [],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: map,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: id,
},
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: format,
},
Punct {
char: '!',
spacing: Alone,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: " data-hk=\"{id}\"",
span: bytes(389..408),
},
],
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: unwrap_or_default,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
Punct {
char: ',',
spacing: Alone,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos_dom,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: HydrationCtx,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: id,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: map,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: id,
},
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: format,
},
Punct {
char: '!',
spacing: Alone,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: " data-hk=\"{id}\"",
span: bytes(409..428),
},
],
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: unwrap_or_default,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
],
},
Punct {
@ -1685,7 +1885,7 @@ TokenStream [
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: "!</span><button id=\"_{}\">+1</button></div>",
lit: "!</span><button{}>+1</button></div>",
},
Punct {
char: ',',
@ -1739,6 +1939,56 @@ TokenStream [
delimiter: Parenthesis,
stream: TokenStream [],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: map,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: id,
},
Punct {
char: '|',
spacing: Alone,
},
Ident {
sym: format,
},
Punct {
char: '!',
spacing: Alone,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: " data-hk=\"{id}\"",
span: bytes(435..454),
},
],
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: unwrap_or_default,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
],
},
Punct {

View file

@ -32,11 +32,15 @@ fn view() {
[
leptos::leptos_dom::html::StringOrView::String(
format!(
"<div id=\"_{}\"><button id=\"_{}\">Clear</button><button id=\"_{}\">-1</button><span id=\"_{}\">Value: ",
::leptos::leptos_dom::HydrationCtx::peek(),
::leptos::leptos_dom::HydrationCtx::id(),
::leptos::leptos_dom::HydrationCtx::id(),
::leptos::leptos_dom::HydrationCtx::id()
"<div{}><button{}>Clear</button><button{}>-1</button><span{}>Value: ",
::leptos::leptos_dom::HydrationCtx::peek().map(| id |
format!(" data-hk=\"{id}\"")).unwrap_or_default(),
::leptos::leptos_dom::HydrationCtx::id().map(| id |
format!(" data-hk=\"{id}\"")).unwrap_or_default(),
::leptos::leptos_dom::HydrationCtx::id().map(| id |
format!(" data-hk=\"{id}\"")).unwrap_or_default(),
::leptos::leptos_dom::HydrationCtx::id().map(| id |
format!(" data-hk=\"{id}\"")).unwrap_or_default()
)
.into(),
),
@ -49,8 +53,9 @@ fn view() {
},
leptos::leptos_dom::html::StringOrView::String(
format!(
"!</span><button id=\"_{}\">+1</button></div>",
::leptos::leptos_dom::HydrationCtx::id()
"!</span><button{}>+1</button></div>",
::leptos::leptos_dom::HydrationCtx::id().map(| id |
format!(" data-hk=\"{id}\"")).unwrap_or_default()
)
.into(),
),

View file

@ -72,6 +72,7 @@ serde = []
serde-lite = ["dep:serde-lite"]
miniserde = ["dep:miniserde"]
rkyv = ["dep:rkyv", "dep:bytecheck"]
experimental-islands = []
[package.metadata.cargo-all-features]
denylist = ["nightly"]

View file

@ -2,10 +2,11 @@ use crate::{
runtime::PinnedFuture, suspense::StreamChunk, with_runtime, ResourceId,
SuspenseContext,
};
use cfg_if::cfg_if;
use futures::stream::FuturesUnordered;
#[cfg(feature = "experimental-islands")]
use std::cell::Cell;
use std::collections::{HashMap, HashSet, VecDeque};
#[doc(hidden)]
/// Hydration data and other context that is shared between the server
/// and the client.
pub struct SharedContext {
@ -17,6 +18,8 @@ pub struct SharedContext {
pub resolved_resources: HashMap<ResourceId, String>,
/// Suspended fragments that have not yet resolved.
pub pending_fragments: HashMap<String, FragmentData>,
#[cfg(feature = "experimental-islands")]
pub no_hydrate: bool,
}
impl SharedContext {
@ -194,41 +197,87 @@ impl Eq for SharedContext {}
#[allow(clippy::derivable_impls)]
impl Default for SharedContext {
fn default() -> Self {
cfg_if! {
if #[cfg(all(feature = "hydrate", target_arch = "wasm32"))] {
let pending_resources = js_sys::Reflect::get(
&web_sys::window().unwrap(),
&wasm_bindgen::JsValue::from_str("__LEPTOS_PENDING_RESOURCES"),
);
let pending_resources: HashSet<ResourceId> = pending_resources
.map_err(|_| ())
.and_then(|pr| serde_wasm_bindgen::from_value(pr).map_err(|_| ()))
.unwrap();
#[cfg(all(feature = "hydrate", target_arch = "wasm32"))]
{
let pending_resources = js_sys::Reflect::get(
&web_sys::window().unwrap(),
&wasm_bindgen::JsValue::from_str("__LEPTOS_PENDING_RESOURCES"),
);
let pending_resources: HashSet<ResourceId> = pending_resources
.map_err(|_| ())
.and_then(|pr| {
serde_wasm_bindgen::from_value(pr).map_err(|_| ())
})
.unwrap();
let resolved_resources = js_sys::Reflect::get(
&web_sys::window().unwrap(),
&wasm_bindgen::JsValue::from_str("__LEPTOS_RESOLVED_RESOURCES"),
)
.unwrap(); // unwrap_or(wasm_bindgen::JsValue::NULL);
let resolved_resources = js_sys::Reflect::get(
&web_sys::window().unwrap(),
&wasm_bindgen::JsValue::from_str("__LEPTOS_RESOLVED_RESOURCES"),
)
.unwrap(); // unwrap_or(wasm_bindgen::JsValue::NULL);
let resolved_resources =
serde_wasm_bindgen::from_value(resolved_resources).unwrap();
let resolved_resources =
serde_wasm_bindgen::from_value(resolved_resources).unwrap();
Self {
server_resources: pending_resources.clone(),
pending_resources,
resolved_resources,
pending_fragments: Default::default(),
}
} else {
Self {
server_resources: Default::default(),
pending_resources: Default::default(),
resolved_resources: Default::default(),
pending_fragments: Default::default(),
}
Self {
server_resources: pending_resources.clone(),
//events: Default::default(),
pending_resources,
resolved_resources,
pending_fragments: Default::default(),
#[cfg(feature = "experimental-islands")]
no_hydrate: true,
}
}
#[cfg(not(all(feature = "hydrate", target_arch = "wasm32")))]
{
Self {
server_resources: Default::default(),
//events: Default::default(),
pending_resources: Default::default(),
resolved_resources: Default::default(),
pending_fragments: Default::default(),
#[cfg(feature = "experimental-islands")]
no_hydrate: true,
}
}
}
}
#[cfg(feature = "experimental-islands")]
thread_local! {
pub static NO_HYDRATE: Cell<bool> = Cell::new(true);
}
#[cfg(feature = "experimental-islands")]
impl SharedContext {
/// Whether the renderer should currently add hydration IDs.
pub fn no_hydrate() -> bool {
NO_HYDRATE.with(Cell::get)
}
/// Sets whether the renderer should not add hydration IDs.
pub fn set_no_hydrate(hydrate: bool) {
NO_HYDRATE.with(|cell| cell.set(hydrate));
}
/// Turns on hydration for the duration of the function call
#[inline(always)]
pub fn with_hydration<T>(f: impl FnOnce() -> T) -> T {
let prev = SharedContext::no_hydrate();
SharedContext::set_no_hydrate(false);
let v = f();
SharedContext::set_no_hydrate(prev);
v
}
/// Turns off hydration for the duration of the function call
#[inline(always)]
pub fn no_hydration<T>(f: impl FnOnce() -> T) -> T {
let prev = SharedContext::no_hydrate();
SharedContext::set_no_hydrate(true);
let v = f();
SharedContext::set_no_hydrate(prev);
v
}
}

View file

@ -1,3 +1,5 @@
#[cfg(feature = "experimental-islands")]
use crate::SharedContext;
#[cfg(debug_assertions)]
use crate::SpecialNonReactiveZone;
use crate::{
@ -204,6 +206,8 @@ where
version: Rc::new(Cell::new(0)),
suspense_contexts: Default::default(),
serializable,
#[cfg(feature = "experimental-islands")]
should_send_to_client: Default::default(),
});
let id = with_runtime(|runtime| {
@ -335,6 +339,8 @@ where
version: Rc::new(Cell::new(0)),
suspense_contexts: Default::default(),
serializable: ResourceSerialization::Local,
#[cfg(feature = "experimental-islands")]
should_send_to_client: Default::default(),
});
let id = with_runtime(|runtime| {
@ -1078,6 +1084,8 @@ where
version: Rc<Cell<usize>>,
suspense_contexts: Rc<RefCell<HashSet<SuspenseContext>>>,
serializable: ResourceSerialization,
#[cfg(feature = "experimental-islands")]
should_send_to_client: Rc<Cell<Option<bool>>>,
}
/// Whether and how the resource can be serialized.
@ -1255,6 +1263,15 @@ where
return;
}
// if it's 1) in normal mode and is read, or
// 2) is in island mode and read in an island, tell it to ship
#[cfg(feature = "experimental-islands")]
if self.should_send_to_client.get().is_none()
&& !SharedContext::no_hydrate()
{
self.should_send_to_client.set(Some(true));
}
let version = self.version.get() + 1;
self.version.set(version);
self.scheduled.set(false);
@ -1363,6 +1380,8 @@ pub(crate) trait SerializableResource {
&self,
id: ResourceId,
) -> Pin<Box<dyn Future<Output = (ResourceId, String)>>>;
fn should_send_to_client(&self) -> bool;
}
impl<S, T> SerializableResource for ResourceState<S, T>
@ -1373,10 +1392,12 @@ where
fn as_any(&self) -> &dyn Any {
self
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(level = "trace", skip_all,)
)]
#[inline(always)]
fn to_serialization_resolver(
&self,
id: ResourceId,
@ -1384,6 +1405,22 @@ where
let fut = self.resource_to_serialization_resolver(id);
Box::pin(fut)
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(level = "trace", skip_all,)
)]
#[inline(always)]
fn should_send_to_client(&self) -> bool {
#[cfg(feature = "experimental-islands")]
{
self.should_send_to_client.get() == Some(true)
}
#[cfg(not(feature = "experimental-islands"))]
{
true
}
}
}
pub(crate) trait UnserializableResource {

View file

@ -1138,8 +1138,8 @@ impl Runtime {
.borrow()
.iter()
.filter_map(|(resource_id, res)| {
if matches!(res, AnyResource::Serializable(_)) {
Some(resource_id)
if let AnyResource::Serializable(res) = res {
res.should_send_to_client().then_some(resource_id)
} else {
None
}
@ -1154,7 +1154,9 @@ impl Runtime {
let resources = { self.resources.borrow().clone() };
for (id, resource) in resources.iter() {
if let AnyResource::Serializable(resource) = resource {
f.push(resource.to_serialization_resolver(id));
if resource.should_send_to_client() {
f.push(resource.to_serialization_resolver(id));
}
}
}
f

View file

@ -80,3 +80,12 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for RwSignal<T> {
T::deserialize(deserializer).map(create_rw_signal)
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for MaybeSignal<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
T::deserialize(deserializer).map(MaybeSignal::Static)
}
}

View file

@ -275,7 +275,9 @@ impl MetaContext {
}
tags.push_str(&self.tags.as_string());
HydrationCtx::continue_from(prev_key);
if let Some(prev_key) = prev_key {
HydrationCtx::continue_from(prev_key);
}
tags
}
}

View file

@ -146,12 +146,12 @@ impl History for BrowserIntegration {
/// ```
/// # use leptos_router::*;
/// # use leptos::*;
/// # let runtime = create_runtime();
/// # let rt = create_runtime();
/// let integration = ServerIntegration {
/// path: "http://leptos.rs/".to_string(),
/// };
/// provide_context(RouterIntegrationContext::new(integration));
/// # runtime.dispose();
/// # rt.dispose();
/// ```
#[derive(Clone)]
pub struct RouterIntegrationContext(pub Rc<dyn History>);
@ -183,13 +183,13 @@ impl History for RouterIntegrationContext {
/// ```
/// # use leptos_router::*;
/// # use leptos::*;
/// # let runtime = create_runtime();
/// # let rt = create_runtime();
/// let integration = ServerIntegration {
/// // Swap out with your URL if integrating manually.
/// path: "http://leptos.rs/".to_string(),
/// };
/// provide_context(RouterIntegrationContext::new(integration));
/// # runtime.dispose();
/// # rt.dispose();
/// ```
#[derive(Clone, Debug)]
pub struct ServerIntegration {