feat: "islands router" for client-side navigations when in islands mode (#3502)

This commit is contained in:
Greg Johnston
2025-02-28 14:01:33 -05:00
committed by GitHub
parent e7a73595de
commit cdee2a9476
22 changed files with 3912 additions and 287 deletions

View File

@@ -14,7 +14,7 @@ leptos = { path = "../../leptos", features = ["tracing", "islands"] }
leptos_router = { path = "../../router" }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", features = [
"dont-use-islands-router",
"islands-router",
], optional = true }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
@@ -22,7 +22,8 @@ axum = { version = "0.8.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
wasm-bindgen = "0.2.93"
wasm-bindgen = "0.2.100"
serde_json = "1.0.133"
[features]
hydrate = ["leptos/hydrate"]
@@ -55,11 +56,11 @@ site-root = "target/site"
# 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"
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 = "127.0.0.1:3009"
# The port to use for automatic reload monitoring
reload-port = 3001
# The browserlist query used for optimizing the CSS.

File diff suppressed because it is too large Load Diff

View File

@@ -1,140 +0,0 @@
window.addEventListener("click", async (ev) => {
// confirm that this is an <a> that meets our requirements
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.altKey ||
ev.ctrlKey ||
ev.shiftKey
)
return;
/** @type HTMLAnchorElement | undefined;*/
const a = ev
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a) return;
const svg = a.namespaceURI === "http://www.w3.org/2000/svg";
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state"))) return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel && rel.includes("external"))) return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (
url.origin !== window.location.origin // ||
// TODO base
//(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase()))
)
return;
ev.preventDefault();
// fetch the new page
const resp = await fetch(url);
const htmlString = await resp.text();
// Use DOMParser to parse the HTML string
const parser = new DOMParser();
// TODO parse from the request stream instead?
const doc = parser.parseFromString(htmlString, 'text/html');
// The 'doc' variable now contains the parsed DOM
const transition = async () => {
const oldDocWalker = document.createTreeWalker(document);
const newDocWalker = doc.createTreeWalker(doc);
let oldNode = oldDocWalker.currentNode;
let newNode = newDocWalker.currentNode;
while(oldDocWalker.nextNode() && newDocWalker.nextNode()) {
oldNode = oldDocWalker.currentNode;
newNode = newDocWalker.currentNode;
// if the nodes are different, we need to replace the old with the new
// because of the typed view tree, this should never actually happen
if (oldNode.nodeType !== newNode.nodeType) {
oldNode.replaceWith(newNode);
}
// if it's a text node, just update the text with the new text
else if (oldNode.nodeType === Node.TEXT_NODE) {
oldNode.textContent = newNode.textContent;
}
// if it's an element, replace if it's a different tag, or update attributes
else if (oldNode.nodeType === Node.ELEMENT_NODE) {
/** @type Element */
const oldEl = oldNode;
/** @type Element */
const newEl = newNode;
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
}
else {
for(const attr of newEl.attributes) {
oldEl.setAttribute(attr.name, attr.value);
}
}
}
// we use comment "branch marker" nodes to distinguish between different branches in the statically-typed view tree
// if one of these marker is hit, then there are two options
// 1) it's the same branch, and we just keep walking until the end
// 2) it's a different branch, in which case the old can be replaced with the new wholesale
else if (oldNode.nodeType === Node.COMMENT_NODE) {
const oldText = oldNode.textContent;
const newText = newNode.textContent;
if(oldText.startsWith("bo") && newText !== oldText) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
while(oldBranches > 0 && newBranches > 0) {
if(oldDocWalker.nextNode() && newDocWalker.nextNode()) {
console.log(oldDocWalker.currentNode, newDocWalker.currentNode);
if(oldDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(oldDocWalker.currentNode.textContent.startsWith("bo")) {
oldBranches += 1;
} else if(oldDocWalker.currentNode.textContent.startsWith("bc")) {
oldBranches -= 1;
}
}
if(newDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(newDocWalker.currentNode.textContent.startsWith("bo")) {
newBranches += 1;
} else if(newDocWalker.currentNode.textContent.startsWith("bc")) {
newBranches -= 1;
}
}
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndBefore(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
} }
}
};
// Not all browsers support startViewTransition; see https://caniuse.com/?search=startViewTransition
if (document.startViewTransition) {
await document.startViewTransition(transition);
} else {
await transition()
}
window.history.pushState(undefined, null, url);
});

View File

@@ -1,8 +1,13 @@
use leptos::prelude::*;
use leptos_router::{
components::{FlatRoutes, Route, Router},
StaticSegment,
use leptos::{
either::{Either, EitherOf3},
prelude::*,
};
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_params_map, use_query_map},
path,
};
use serde::{Deserialize, Serialize};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -12,7 +17,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone()/>
<HydrationScripts options=options islands=true/>
<HydrationScripts options=options islands=true islands_router=true/>
<link rel="stylesheet" id="leptos" href="/pkg/islands.css"/>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico"/>
</head>
@@ -26,34 +31,180 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
#[component]
pub fn App() -> impl IntoView {
view! {
<script src="/routing.js"></script>
<Router>
<header>
<h1>"My Application"</h1>
<h1>"My Contacts"</h1>
</header>
<nav>
<a href="/">"Page A"</a>
<a href="/b">"Page B"</a>
<a href="/">"Home"</a>
<a href="/about">"About"</a>
</nav>
<main>
<p>
<label>"Home Checkbox" <input type="checkbox"/></label>
</p>
<FlatRoutes fallback=|| "Not found.">
<Route path=StaticSegment("") view=PageA/>
<Route path=StaticSegment("b") view=PageB/>
</FlatRoutes>
<Routes fallback=|| "Not found.">
<Route path=path!("") view=Home/>
<Route path=path!("user/:id") view=Details/>
<Route path=path!("about") view=About/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn PageA() -> impl IntoView {
view! { <label>"Page A" <input type="checkbox"/></label> }
#[server]
pub async fn search(query: String) -> Result<Vec<User>, ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let data: Vec<User> = serde_json::from_str(&users)?;
let query = query.to_ascii_lowercase();
Ok(data
.into_iter()
.filter(|user| {
user.first_name.to_ascii_lowercase().contains(&query)
|| user.last_name.to_ascii_lowercase().contains(&query)
|| user.email.to_ascii_lowercase().contains(&query)
})
.collect())
}
#[server]
pub async fn delete_user(id: u32) -> Result<(), ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let mut data: Vec<User> = serde_json::from_str(&users)?;
data.retain(|user| user.id != id);
let new_json = serde_json::to_string(&data)?;
tokio::fs::write("./mock_data.json", &new_json).await?;
Ok(())
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct User {
id: u32,
first_name: String,
last_name: String,
email: String,
}
#[component]
pub fn PageB() -> impl IntoView {
view! { <label>"Page B" <input type="checkbox"/></label> }
pub fn Home() -> impl IntoView {
let q = use_query_map();
let q = move || q.read().get("q");
let data = Resource::new(q, |q| async move {
if let Some(q) = q {
search(q).await
} else {
Ok(vec![])
}
});
let delete_user_action = ServerAction::<DeleteUser>::new();
let view = move || {
Suspend::new(async move {
let users = data.await.unwrap();
if q().is_none() {
EitherOf3::A(view! {
<p class="note">"Enter a search to begin viewing contacts."</p>
})
} else if users.is_empty() {
EitherOf3::B(view! {
<p class="note">"No users found matching that search."</p>
})
} else {
EitherOf3::C(view! {
<table>
<tbody>
<For
each=move || users.clone()
key=|user| user.id
let:user
>
<tr>
<td>{user.first_name}</td>
<td>{user.last_name}</td>
<td>{user.email}</td>
<td>
<a href=format!("/user/{}", user.id)>"Details"</a>
<input type="checkbox"/>
<ActionForm action=delete_user_action>
<input type="hidden" name="id" value=user.id/>
<input type="submit" value="Delete"/>
</ActionForm>
</td>
</tr>
</For>
</tbody>
</table>
})
}
})
};
view! {
<section class="page">
<form method="GET" class="search">
<input type="search" name="q" value=q autofocus oninput="this.form.requestSubmit()"/>
<input type="submit"/>
</form>
<Suspense fallback=|| view! { <p>"Loading..."</p> }>{view}</Suspense>
</section>
}
}
#[component]
pub fn Details() -> impl IntoView {
#[server]
pub async fn get_user(id: u32) -> Result<Option<User>, ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let data: Vec<User> = serde_json::from_str(&users)?;
Ok(data.iter().find(|user| user.id == id).cloned())
}
let params = use_params_map();
let id = move || {
params
.read()
.get("id")
.and_then(|id| id.parse::<u32>().ok())
};
let user = Resource::new(id, |id| async move {
match id {
None => Ok(None),
Some(id) => get_user(id).await,
}
});
move || {
Suspend::new(async move {
user.await.map(|user| match user {
None => Either::Left(view! {
<section class="page">
<h2>"Not found."</h2>
<p>"Sorry — we couldnt find that user."</p>
</section>
}),
Some(user) => Either::Right(view! {
<section class="page">
<h2>{user.first_name} " " { user.last_name}</h2>
<p class="email">{user.email}</p>
</section>
}),
})
})
}
}
#[component]
pub fn About() -> impl IntoView {
view! {
<section class="page">
<h2>"About"</h2>
<p>"This demo is intended to show off an experimental “islands router” feature, which mimics the smooth transitions and user experience of client-side routing while minimizing the amount of code that actually runs in the browser."</p>
<p>"By default, all the content in this application is only rendered on the server. But you can add client-side interactivity via islands like this one:"</p>
<Counter/>
</section>
}
}
#[island]
pub fn Counter() -> impl IntoView {
let count = RwSignal::new(0);
view! {
<button class="counter" on:click=move |_| *count.write() += 1>{count}</button>
}
}

View File

@@ -1,3 +1,52 @@
.pending {
color: purple;
body {
font-family: system-ui, sans-serif;
background-color: #f6f6fa;
}
h1, h2, h3, h4, h5, h6 {
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari, 'Arial Rounded MT', 'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
text-align: center;
}
nav {
padding: 1rem;
text-align: center;
}
nav a {
margin: 1rem;
}
form.search {
display: flex;
margin: 2rem auto;
justify-content: center;
}
td {
min-width: 10rem;
width: 10rem;
}
table {
min-width: 100%;
}
.page {
width: 80%;
margin: auto;
}
td:last-child > * {
display: inline-block;
}
.note, .note {
text-align: center;
}
button.counter {
display: block;
font-size: 2rem;
margin: auto;
}

22
examples/tailwind_axum/package-lock.json generated Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "leptos-tailwind",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "leptos-tailwind",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"tailwindcss": "^4.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz",
"integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==",
"dev": true
}
}
}

View File

@@ -0,0 +1,69 @@
/*! tailwindcss v4.0.0 | MIT License | https://tailwindcss.com */
@tailwind base;
@tailwind components;
.relative {
position: relative;
}
.m-auto {
margin: auto;
}
.flex {
display: flex;
}
.min-h-screen {
min-height: 100vh;
}
.transform {
transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
}
.flex-col {
flex-direction: column;
}
.flex-row-reverse {
flex-direction: row-reverse;
}
.flex-wrap {
flex-wrap: wrap;
}
.border-b-4 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 4px;
}
.border-l-2 {
border-left-style: var(--tw-border-style);
border-left-width: 2px;
}
.bg-gradient-to-tl {
--tw-gradient-position: to top left in oklab,;
background-image: linear-gradient(var(--tw-gradient-stops));
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
initial-value: rotateX(0);
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
initial-value: rotateY(0);
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
initial-value: rotateZ(0);
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
initial-value: skewX(0);
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
initial-value: skewY(0);
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}

View File

@@ -33,7 +33,7 @@ once_cell = "1"
rustdoc-args = ["--generate-link-to-definition"]
[features]
dont-use-islands-router = []
islands-router = []
tracing = ["dep:tracing"]
[package.metadata.cargo-all-features]

View File

@@ -23,6 +23,7 @@ use hydration_context::SsrSharedContext;
use leptos::{
config::LeptosOptions,
context::{provide_context, use_context},
hydration::IslandsRouterNavigation,
prelude::expect_context,
reactive::{computed::ScopedFuture, owner::Owner},
IntoView,
@@ -654,12 +655,27 @@ where
IV: IntoView + 'static,
{
_ = replace_blocks; // TODO
handle_response(method, additional_context, app_fn, |app, chunks| {
Box::pin(async move {
Box::pin(app.to_html_stream_out_of_order().chain(chunks()))
as PinnedStream<String>
})
})
handle_response(
method,
additional_context,
app_fn,
|app, chunks, supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
if supports_ooo {
app.to_html_stream_out_of_order_branching()
} else {
app.to_html_stream_in_order_branching()
}
} else if supports_ooo {
app.to_html_stream_out_of_order()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
},
)
}
/// Returns an Actix [struct@Route](actix_web::Route) that listens for a `GET` request and tries
@@ -685,12 +701,21 @@ pub fn render_app_to_stream_in_order_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(method, additional_context, app_fn, |app, chunks| {
Box::pin(async move {
Box::pin(app.to_html_stream_in_order().chain(chunks()))
as PinnedStream<String>
})
})
handle_response(
method,
additional_context,
app_fn,
|app, chunks, _supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
},
)
}
/// Returns an Actix [struct@Route](actix_web::Route) that listens for a `GET` request and tries
@@ -722,12 +747,13 @@ where
fn async_stream_builder<IV>(
app: IV,
chunks: BoxedFnOnce<PinnedStream<String>>,
_supports_ooo: bool,
) -> PinnedFuture<PinnedStream<String>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -767,6 +793,7 @@ fn leptos_corrected_path(req: &HttpRequest) -> String {
}
}
#[allow(clippy::type_complexity)]
fn handle_response<IV>(
method: Method,
additional_context: impl Fn() + 'static + Clone + Send,
@@ -774,6 +801,7 @@ fn handle_response<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> Route
where
@@ -784,6 +812,9 @@ where
let add_context = additional_context.clone();
async move {
let is_island_router_navigation = cfg!(feature = "islands-router")
&& req.headers().get("Islands-Router").is_some();
let res_options = ResponseOptions::default();
let (meta_context, meta_output) = ServerMetaContext::new();
@@ -794,6 +825,10 @@ where
move || {
provide_contexts(req, &meta_context, &res_options);
add_context();
if is_island_router_navigation {
provide_context(IslandsRouterNavigation);
}
}
};
@@ -803,6 +838,7 @@ where
additional_context,
res_options,
stream_builder,
!is_island_router_navigation,
)
.await;
@@ -1093,6 +1129,7 @@ impl StaticRouteGenerator {
app_fn.clone(),
additional_context,
async_stream_builder,
false,
);
let sc = owner.shared_context().unwrap();

View File

@@ -36,7 +36,7 @@ tokio = { version = "1.43", features = ["net", "rt-multi-thread"] }
[features]
wasm = []
default = ["tokio/fs", "tokio/sync", "tower-http/fs", "tower/util"]
dont-use-islands-router = []
islands-router = []
tracing = ["dep:tracing"]
[package.metadata.docs.rs]

View File

@@ -773,12 +773,18 @@ where
IV: IntoView + 'static,
{
_ = replace_blocks; // TODO
handle_response(additional_context, app_fn, |app, chunks| {
handle_response(additional_context, app_fn, |app, chunks, supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
app.to_html_stream_out_of_order_branching()
} else {
let app = if cfg!(feature = "islands-router") {
if supports_ooo {
app.to_html_stream_out_of_order_branching()
} else {
app.to_html_stream_in_order_branching()
}
} else if supports_ooo {
app.to_html_stream_out_of_order()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
@@ -838,8 +844,8 @@ pub fn render_app_to_stream_in_order_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(additional_context, app_fn, |app, chunks| {
let app = if cfg!(feature = "dont-use-islands-router") {
handle_response(additional_context, app_fn, |app, chunks, _supports_ooo| {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -856,6 +862,7 @@ fn handle_response<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> impl Fn(Request<Body>) -> PinnedFuture<Response<Body>>
+ Clone
@@ -879,12 +886,16 @@ fn handle_response_inner<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> PinnedFuture<Response<Body>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let is_island_router_navigation = cfg!(feature = "islands-router")
&& req.headers().get("Islands-Router").is_some();
let add_context = additional_context.clone();
let res_options = ResponseOptions::default();
let (meta_context, meta_output) = ServerMetaContext::new();
@@ -906,6 +917,10 @@ where
res_options.clone(),
);
add_context();
if is_island_router_navigation {
provide_context(IslandsRouterNavigation);
}
}
};
@@ -915,6 +930,7 @@ where
additional_context,
res_options,
stream_builder,
!is_island_router_navigation,
)
.await;
@@ -1054,9 +1070,9 @@ pub fn render_app_async_stream_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(additional_context, app_fn, |app, chunks| {
handle_response(additional_context, app_fn, |app, chunks, _supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -1127,12 +1143,13 @@ where
fn async_stream_builder<IV>(
app: IV,
chunks: BoxedFnOnce<PinnedStream<String>>,
_supports_ooo: bool,
) -> PinnedFuture<PinnedStream<String>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -1405,6 +1422,7 @@ impl StaticRouteGenerator {
app_fn.clone(),
additional_context,
async_stream_builder,
false,
);
let sc = owner.shared_context().unwrap();
@@ -1837,64 +1855,64 @@ where
}
} else {
router.route(
path,
match listing.mode() {
SsrMode::OutOfOrder => {
let s = render_app_to_stream_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
path,
match listing.mode() {
SsrMode::OutOfOrder => {
let s = render_app_to_stream_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
SsrMode::PartiallyBlocked => {
let s = render_app_to_stream_with_context_and_replace_blocks(
cx_with_state_and_method.clone(),
app_fn.clone(),
true
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
SsrMode::PartiallyBlocked => {
let s = render_app_to_stream_with_context_and_replace_blocks(
cx_with_state_and_method.clone(),
app_fn.clone(),
true
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
SsrMode::InOrder => {
let s = render_app_to_stream_in_order_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
SsrMode::InOrder => {
let s = render_app_to_stream_in_order_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
SsrMode::Async => {
let s = render_app_async_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
SsrMode::Async => {
let s = render_app_async_with_context(
cx_with_state_and_method.clone(),
app_fn.clone(),
);
match method {
leptos_router::Method::Get => get(s),
leptos_router::Method::Post => post(s),
leptos_router::Method::Put => put(s),
leptos_router::Method::Delete => delete(s),
leptos_router::Method::Patch => patch(s),
}
}
}
_ => unreachable!()
},
)
_ => unreachable!()
},
)
};
}
}
@@ -2028,7 +2046,7 @@ where
},
move || shell(options),
req,
|app, chunks| {
|app, chunks, _supports_ooo| {
Box::pin(async move {
let app = app
.to_html_stream_in_order()

View File

@@ -1,3 +1,5 @@
#![allow(clippy::type_complexity)]
use futures::{stream::once, Stream, StreamExt};
use hydration_context::{SharedContext, SsrSharedContext};
use leptos::{
@@ -31,14 +33,20 @@ pub trait ExtendResponse: Sized {
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
supports_ooo: bool,
) -> impl Future<Output = Self> + Send
where
IV: IntoView + 'static,
{
async move {
let (owner, stream) =
build_response(app_fn, additional_context, stream_builder);
let (owner, stream) = build_response(
app_fn,
additional_context,
stream_builder,
supports_ooo,
);
let sc = owner.shared_context().unwrap();
@@ -94,7 +102,11 @@ pub fn build_response<IV>(
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
// this argument indicates whether a request wants to support out-of-order streaming
// responses
bool,
) -> PinnedFuture<PinnedStream<String>>,
is_islands_router_navigation: bool,
) -> (Owner, PinnedFuture<PinnedStream<String>>)
where
IV: IntoView + 'static,
@@ -138,7 +150,7 @@ where
//
// we also don't actually start hydrating until after the whole stream is complete,
// so it's not useful to send those scripts down earlier.
stream_builder(app, chunks)
stream_builder(app, chunks, is_islands_router_navigation)
});
stream.await

View File

@@ -316,7 +316,7 @@ mod tests {
#[test]
fn callback_matches_same() {
let callback1 = Callback::new(|x: i32| x * 2);
let callback2 = callback1.clone();
let callback2 = callback1;
assert!(callback1.matches(&callback2));
}
@@ -330,7 +330,7 @@ mod tests {
#[test]
fn unsync_callback_matches_same() {
let callback1 = UnsyncCallback::new(|x: i32| x * 2);
let callback2 = callback1.clone();
let callback2 = callback1;
assert!(callback1.matches(&callback2));
}

View File

@@ -121,7 +121,7 @@ where
EF: Fn(T) -> N + Send + Clone + 'static,
N: IntoView + 'static,
KF: Fn(&T) -> K + Send + Clone + 'static,
K: Eq + Hash + 'static,
K: Eq + Hash + ToString + 'static,
T: Send + 'static,
{
// this takes the owner of the For itself
@@ -195,7 +195,7 @@ where
EF: Fn(ReadSignal<usize>, T) -> N + Send + Clone + 'static,
N: IntoView + 'static,
KF: Fn(&T) -> K + Send + Clone + 'static,
K: Eq + Hash + 'static,
K: Eq + Hash + ToString + 'static,
T: Send + 'static,
{
// this takes the owner of the For itself

View File

@@ -52,6 +52,8 @@
mod.hydrate();
hydrateIslands(document.body, mod);
});
window.__hydrateIsland = (el, id) => hydrateIsland(el, id, mod);
})
});
})

View File

@@ -0,0 +1,378 @@
let NAVIGATION = 0;
window.addEventListener("click", async (ev) => {
const req = clickToReq(ev);
if(!req) {
return;
}
ev.preventDefault();
await navigateToPage(req, true);
});
window.addEventListener("popstate", async (ev) => {
const req = new Request(window.location);
ev.preventDefault();
await navigateToPage(req, true, true);
});
window.addEventListener("submit", async (ev) => {
const req = submitToReq(ev);
if(!req) {
return;
}
ev.preventDefault();
await navigateToPage(req, true);
});
async function navigateToPage(
/** @type Request */
req,
/** @type bool */
useViewTransition,
/** @type bool */
replace
) {
NAVIGATION += 1;
const currentNav = NAVIGATION;
// add a custom header to indicate that we're on a subsequent navigation
req.headers.append("Islands-Router", "true");
// fetch the new page
const resp = await fetch(req);
const redirected = resp.redirected;
const htmlString = await resp.text();
if(NAVIGATION === currentNav) {
// The 'doc' variable now contains the parsed DOM
const transition = async () => {
try {
diffPages(htmlString);
for(const island of document.querySelectorAll("leptos-island")) {
if(!island.$$hydrated) {
__hydrateIsland(island, island.dataset.component);
island.$$hydrated = true;
}
}
} catch(e) {
console.error(e);
}
};
// Not all browsers support startViewTransition; see https://caniuse.com/?search=startViewTransition
if (useViewTransition && document.startViewTransition) {
await document.startViewTransition(transition);
} else {
await transition()
}
const url = redirected ? resp.url : req.url;
if(replace) {
window.history.replaceState(undefined, null, url);
} else {
window.history.pushState(undefined, null, url);
}
}
}
function clickToReq(ev) {
// confirm that this is an <a> that meets our requirements
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.altKey ||
ev.ctrlKey ||
ev.shiftKey
)
return;
/** @type HTMLAnchorElement | undefined;*/
const a = ev
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a) return;
const svg = a.namespaceURI === "http://www.w3.org/2000/svg";
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state"))) return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel?.includes("external"))) return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (
url.origin !== window.location.origin // ||
// TODO base
//(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase()))
)
return;
return new Request(url);
}
function submitToReq(ev) {
event.preventDefault();
const target = ev.target;
/** @type HTMLFormElement */
let form;
if(target instanceof HTMLFormElement) {
form = target;
} else {
if(!target.form) {
return;
}
form = target.form;
}
const method = form.method.toUpperCase();
if(method !== "GET" && method !== "POST") {
return;
}
const url = new URL(form.action);
let path = url.pathname;
const requestInit = {};
const data = new FormData(form);
const params = new URLSearchParams();
for (const [key, value] of data.entries()) {
params.append(key, value);
}
requestInit.headers = {
Accept: "text/html"
};
if(method === "GET") {
path += `?${params.toString()}`;
}
else {
requestInit.method = "POST";
requestInit.body = params;
}
return new Request(
path,
requestInit
);
}
function diffPages(htmlString) {
// Use DOMParser to parse the HTML string
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, 'text/html');
diffRange(document, document, doc, doc);
}
function diffRange(oldDocument, oldRoot, newDocument, newRoot, oldEnd, newEnd) {
const oldDocWalker = oldDocument.createTreeWalker(oldRoot);
const newDocWalker = newDocument.createTreeWalker(newRoot);
let oldNode = oldDocWalker.currentNode;
let newNode = newDocWalker.currentNode;
while (oldDocWalker.nextNode() && newDocWalker.nextNode()) {
oldNode = oldDocWalker.currentNode;
newNode = newDocWalker.currentNode;
if (oldNode == oldEnd || newNode == newEnd) {
break;
}
// if the nodes are different, we need to replace the old with the new
// because of the typed view tree, this should never actually happen
if (oldNode.nodeType !== newNode.nodeType) {
oldNode.replaceWith(newNode);
}
// if it's a text node, just update the text with the new text
else if (oldNode.nodeType === Node.TEXT_NODE) {
oldNode.textContent = newNode.textContent;
}
// if it's an element, replace if it's a different tag, or update attributes
else if (oldNode.nodeType === Node.ELEMENT_NODE) {
diffElement(oldNode, newNode);
}
// we use comment "branch marker" nodes to distinguish between different branches in the statically-typed view tree
// if one of these marker is hit, then there are two options
// 1) it's the same branch, and we just keep walking until the end
// 2) it's a different branch, in which case the old can be replaced with the new wholesale
else if (oldNode.nodeType === Node.COMMENT_NODE) {
const oldText = oldNode.textContent;
const newText = newNode.textContent;
if(oldText.startsWith("bo-for")) {
replaceFor(oldDocument, oldDocWalker, newDocument, newDocWalker, oldNode, newNode);
}
else if (oldText.startsWith("bo-item")) {
// skip, this means we're diffing a new item within a For
}
else if(oldText.startsWith("bo") && newText !== oldText) {
replaceBranch(oldDocWalker, newDocWalker, oldNode, newNode);
}
}
}
}
function replaceFor(oldDocument, oldDocWalker, newDocument, newDocWalker, oldNode, newNode) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
const oldKeys = {};
const newKeys = {};
while(oldBranches > 0) {
const c = oldDocWalker.currentNode;
if(c.nodeType === Node.COMMENT_NODE) {
const t = c.textContent;
if(t.startsWith("bo-for")) {
oldBranches += 1;
} else if(t.startsWith("bc-for")) {
oldBranches -= 1;
} else if (t.startsWith("bo-item")) {
const k = t.replace("bo-item-", "");
oldKeys[k] = { open: c, close: null };
} else if (t.startsWith("bc-item")) {
const k = t.replace("bc-item-", "");
oldKeys[k].close = c;
}
}
oldDocWalker.nextNode();
}
while(newBranches > 0) {
const c = newDocWalker.currentNode;
if(c.nodeType === Node.COMMENT_NODE) {
const t = c.textContent;
if(t.startsWith("bo-for")) {
newBranches += 1;
} else if(t.startsWith("bc-for")) {
newBranches -= 1;
} else if (t.startsWith("bo-item")) {
const k = t.replace("bo-item-", "");
newKeys[k] = { open: c, close: null };
} else if (t.startsWith("bc-item")) {
const k = t.replace("bc-item-", "");
newKeys[k].close = c;
}
}
newDocWalker.nextNode();
}
for(const key in oldKeys) {
if(newKeys[key]) {
const oldOne = oldKeys[key];
const newOne = newKeys[key];
const oldRange = new Range();
const newRange = new Range();
// then replace the item in the *new* list with the *old* DOM elements
oldRange.setStartAfter(oldOne.open);
oldRange.setEndBefore(oldOne.close);
newRange.setStartAfter(newOne.open);
newRange.setEndBefore(newOne.close);
const oldContents = oldRange.extractContents();
const newContents = newRange.extractContents();
// patch the *old* DOM elements with the new ones
diffRange(oldDocument, oldContents, newDocument, newContents, oldOne.close, newOne.close);
// then insert the old DOM elements into the new tree
// this means you'll end up with any new attributes or content from the server,
// but with any old DOM state (because they are the old elements)
newRange.insertNode(oldContents);
newOne.open.replaceWith(oldOne.open);
newOne.close.replaceWith(oldOne.close);
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndAfter(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
}
function replaceBranch(oldDocWalker, newDocWalker, oldNode, newNode) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
while(oldBranches > 0) {
if(oldDocWalker.nextNode()) {
if(oldDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(oldDocWalker.currentNode.textContent.startsWith("bo")) {
oldBranches += 1;
} else if(oldDocWalker.currentNode.textContent.startsWith("bc")) {
oldBranches -= 1;
}
}
}
}
while(newBranches > 0) {
if(newDocWalker.nextNode()) {
if(newDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(newDocWalker.currentNode.textContent.startsWith("bo")) {
newBranches += 1;
} else if(newDocWalker.currentNode.textContent.startsWith("bc")) {
newBranches -= 1;
}
}
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndAfter(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
}
function diffElement(oldNode, newNode) {
/** @type Element */
const oldEl = oldNode;
/** @type Element */
const newEl = newNode;
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
}
else {
for(const attr of newEl.attributes) {
oldEl.setAttribute(attr.name, attr.value);
}
}
}
for(const island of document.querySelectorAll("leptos-island")) {
island.$$hydrated = true;
}

View File

@@ -50,6 +50,10 @@ pub fn HydrationScripts(
/// Should be `true` to hydrate in `islands` mode.
#[prop(optional)]
islands: bool,
/// Should be `true` to add the “islands router,” which enables limited client-side routing
/// when running in islands mode.
#[prop(optional)]
islands_router: bool,
/// A base url, not including a trailing slash
#[prop(optional, into)]
root: Option<String>,
@@ -98,18 +102,36 @@ pub fn HydrationScripts(
include_str!("./hydration_script.js")
};
let islands_router = islands_router
.then_some(include_str!("./islands_routing.js"))
.unwrap_or_default();
let root = root.unwrap_or_default();
view! {
<link rel="modulepreload" href=format!("{root}/{pkg_path}/{js_file_name}.js") nonce=nonce.clone()/>
<link
rel="preload"
href=format!("{root}/{pkg_path}/{wasm_file_name}.wasm")
r#as="fetch"
r#type="application/wasm"
crossorigin=nonce.clone().unwrap_or_default()
/>
<script type="module" nonce=nonce>
{format!("{script}({root:?}, {pkg_path:?}, {js_file_name:?}, {wasm_file_name:?})")}
</script>
}
use_context::<IslandsRouterNavigation>().is_none().then(|| {
view! {
<link rel="modulepreload" href=format!("{root}/{pkg_path}/{js_file_name}.js") nonce=nonce.clone()/>
<link
rel="preload"
href=format!("{root}/{pkg_path}/{wasm_file_name}.wasm")
r#as="fetch"
r#type="application/wasm"
crossorigin=nonce.clone().unwrap_or_default()
/>
<script type="module" nonce=nonce>
{format!("{script}({root:?}, {pkg_path:?}, {js_file_name:?}, {wasm_file_name:?});{islands_router}")}
</script>
}
})
}
/// If this is provided via context, it means that you are using the islands router and
/// this is a subsequent navigation, made from the client.
///
/// This should be provided automatically by a server integration if it detects that the
/// header `Islands-Router` is present in the request.
///
/// This is used to determine how much of the hydration script to include in the page.
/// If it is present, then the contents of the `<HydrationScripts>` component will not be
/// included, as they only need to be sent to the client once.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IslandsRouterNavigation;

View File

@@ -10,7 +10,7 @@ use crate::{
use any_spawner::Executor;
use either_of::Either;
use futures::FutureExt;
use leptos::attr::any_attribute::AnyAttribute;
use leptos::attr::{any_attribute::AnyAttribute, Attribute};
use reactive_graph::{
computed::{ArcMemo, ScopedFuture},
owner::{provide_context, Owner},
@@ -27,7 +27,7 @@ use tachys::{
view::{
add_attr::AddAnyAttr,
any_view::{AnyView, AnyViewState, IntoAny},
Mountable, Position, PositionState, Render, RenderHtml,
MarkBranch, Mountable, Position, PositionState, Render, RenderHtml,
},
};
@@ -365,6 +365,112 @@ where
}
}
#[derive(Debug)]
pub(crate) struct MatchedRoute(pub String, pub AnyView);
impl Render for MatchedRoute {
type State = <AnyView as Render>::State;
fn build(self) -> Self::State {
self.1.build()
}
fn rebuild(self, state: &mut Self::State) {
self.1.rebuild(state);
}
}
impl AddAnyAttr for MatchedRoute {
type Output<SomeNewAttr: Attribute> = Self;
fn add_any_attr<NewAttr: Attribute>(
self,
attr: NewAttr,
) -> Self::Output<NewAttr>
where
Self::Output<NewAttr>: RenderHtml,
{
let MatchedRoute(id, view) = self;
MatchedRoute(id, view.add_any_attr(attr).into_any())
}
}
impl RenderHtml for MatchedRoute {
type AsyncOutput = Self;
type Owned = Self;
const MIN_LENGTH: usize = 0;
fn dry_resolve(&mut self) {
self.1.dry_resolve();
}
async fn resolve(self) -> Self::AsyncOutput {
let MatchedRoute(id, view) = self;
let view = view.resolve().await;
MatchedRoute(id, view)
}
fn to_html_with_buf(
self,
buf: &mut String,
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
if mark_branches {
buf.open_branch(&self.0);
}
self.1.to_html_with_buf(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
if mark_branches {
buf.close_branch(&self.0);
}
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
self,
buf: &mut StreamBuilder,
position: &mut Position,
escape: bool,
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) where
Self: Sized,
{
if mark_branches {
buf.open_branch(&self.0);
}
self.1.to_html_async_with_buf::<OUT_OF_ORDER>(
buf,
position,
escape,
mark_branches,
extra_attrs,
);
if mark_branches {
buf.close_branch(&self.0);
}
}
fn hydrate<const FROM_SERVER: bool>(
self,
cursor: &Cursor,
position: &PositionState,
) -> Self::State {
self.1.hydrate::<FROM_SERVER>(cursor, position)
}
fn into_owned(self) -> Self::Owned {
self
}
}
impl<Loc, Defs, FalFn, Fal> FlatRoutesView<Loc, Defs, FalFn>
where
Loc: LocationProvider + Send,
@@ -397,6 +503,7 @@ where
let view = match new_match {
None => (self.fallback)().into_any(),
Some(new_match) => {
let id = new_match.as_matched().to_string();
let (view, _) = new_match.into_view_and_child();
let view = owner
.with(|| {
@@ -409,6 +516,7 @@ where
})
.now_or_never()
.expect("async route used in SSR");
let view = MatchedRoute(id, view);
view.into_any()
}
};

View File

@@ -1,4 +1,5 @@
use crate::{
flat_router::MatchedRoute,
hooks::Matched,
location::{LocationProvider, Url},
matching::RouteDefs,
@@ -642,21 +643,28 @@ where
async move {
provide_context(params_including_parents);
provide_context(url);
provide_context(matched);
provide_context(matched.clone());
view.preload().await;
*view_fn.lock().or_poisoned() = Box::new(move || {
let view = view.clone();
owner.with(|| {
Suspend::new(Box::pin(async move {
let view = SendWrapper::new(ScopedFuture::new(
view.choose(),
));
let view = view.await;
OwnedView::new(view).into_any()
})
as Pin<
Box<dyn Future<Output = AnyView> + Send>,
>)
owner.with({
let matched = matched.clone();
move || {
Suspend::new(Box::pin(async move {
let view = SendWrapper::new(
ScopedFuture::new(view.choose()),
);
let view = view.await;
let view =
MatchedRoute(matched.0.get(), view);
OwnedView::new(view).into_any()
})
as Pin<
Box<
dyn Future<Output = AnyView> + Send,
>,
>)
}
})
});
trigger

View File

@@ -67,11 +67,17 @@ pub struct AnyView {
#[cfg(feature = "ssr")]
dry_resolve: fn(&mut Erased),
#[cfg(feature = "hydrate")]
#[cfg(feature = "hydrate")]
#[allow(clippy::type_complexity)]
hydrate_from_server: fn(Erased, &Cursor, &PositionState) -> AnyViewState,
}
impl Debug for AnyView {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnyView")
.field("type_id", &self.type_id)
.finish_non_exhaustive()
}
}
/// Retained view state for [`AnyView`].
pub struct AnyViewState {
type_id: TypeId,

View File

@@ -1,6 +1,6 @@
use super::{
add_attr::AddAnyAttr, Mountable, Position, PositionState, Render,
RenderHtml,
add_attr::AddAnyAttr, MarkBranch, Mountable, Position, PositionState,
Render, RenderHtml,
};
use crate::{
html::attribute::{any_attribute::AnyAttribute, Attribute},
@@ -66,7 +66,7 @@ where
impl<T, I, K, KF, VF, VFS, V> Render for Keyed<T, I, K, KF, VF, VFS, V>
where
I: IntoIterator<Item = T>,
K: Eq + Hash + 'static,
K: Eq + Hash + ToString + 'static,
KF: Fn(&T) -> K,
V: Render,
VF: Fn(usize, T) -> (VFS, V),
@@ -132,7 +132,7 @@ where
impl<T, I, K, KF, VF, VFS, V> AddAnyAttr for Keyed<T, I, K, KF, VF, VFS, V>
where
I: IntoIterator<Item = T> + Send + 'static,
K: Eq + Hash + 'static,
K: Eq + Hash + ToString + 'static,
KF: Fn(&T) -> K + Send + 'static,
V: RenderHtml,
V: 'static,
@@ -185,7 +185,7 @@ where
impl<T, I, K, KF, VF, VFS, V> RenderHtml for Keyed<T, I, K, KF, VF, VFS, V>
where
I: IntoIterator<Item = T> + Send + 'static,
K: Eq + Hash + 'static,
K: Eq + Hash + ToString + 'static,
KF: Fn(&T) -> K + Send + 'static,
V: RenderHtml + 'static,
VF: Fn(usize, T) -> (VFS, V) + Send + 'static,
@@ -221,8 +221,14 @@ where
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
if mark_branches {
buf.open_branch("for");
}
for (index, item) in self.items.into_iter().enumerate() {
let (_, item) = (self.view_fn)(index, item);
if mark_branches {
buf.open_branch("item");
}
item.to_html_with_buf(
buf,
position,
@@ -230,8 +236,14 @@ where
mark_branches,
extra_attrs.clone(),
);
if mark_branches {
buf.close_branch("item");
}
*position = Position::NextChild;
}
if mark_branches {
buf.close_branch("for");
}
buf.push_str("<!>");
}
@@ -243,8 +255,19 @@ where
mark_branches: bool,
extra_attrs: Vec<AnyAttribute>,
) {
if mark_branches {
buf.open_branch("for");
}
for (index, item) in self.items.into_iter().enumerate() {
let branch_name = mark_branches.then(|| {
let key = (self.key_fn)(&item);
let key = key.to_string();
format!("item-{key}")
});
let (_, item) = (self.view_fn)(index, item);
if mark_branches {
buf.open_branch(branch_name.as_ref().unwrap());
}
item.to_html_async_with_buf::<OUT_OF_ORDER>(
buf,
position,
@@ -252,8 +275,14 @@ where
mark_branches,
extra_attrs.clone(),
);
if mark_branches {
buf.close_branch(branch_name.as_ref().unwrap());
}
*position = Position::NextChild;
}
if mark_branches {
buf.close_branch("for");
}
buf.push_sync("<!>");
}

View File

@@ -46,7 +46,8 @@ pub trait Render: Sized {
fn rebuild(self, state: &mut Self::State);
}
pub(crate) trait MarkBranch {
#[doc(hidden)]
pub trait MarkBranch {
fn open_branch(&mut self, branch_id: &str);
fn close_branch(&mut self, branch_id: &str);