mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-27 09:54:41 -05:00
feat: "islands router" for client-side navigations when in islands mode (#3502)
This commit is contained in:
@@ -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.
|
||||
|
||||
2852
examples/islands_router/mock_data.json
Normal file
2852
examples/islands_router/mock_data.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 couldn’t 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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
22
examples/tailwind_axum/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
69
examples/tailwind_axum/src/style.css
Normal file
69
examples/tailwind_axum/src/style.css
Normal 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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
mod.hydrate();
|
||||
hydrateIslands(document.body, mod);
|
||||
});
|
||||
|
||||
window.__hydrateIsland = (el, id) => hydrateIsland(el, id, mod);
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
378
leptos/src/hydration/islands_routing.js
Normal file
378
leptos/src/hydration/islands_routing.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("<!>");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user