Compare commits

...

23 Commits

Author SHA1 Message Date
Greg Johnston
123b1ef24d docs: clarify difference between set() and update() 2023-05-22 15:33:55 -04:00
Greg Johnston
2d418dae93 fix: debug-mode bugs in <For/> (closes #955, #1075, #1076) (#1078) 2023-05-22 06:49:13 -04:00
Greg Johnston
91e0fcdc1b fix/change: remove ? prefix from search in browser (matching server behavior) - closes #1071 (#1077) 2023-05-21 22:06:38 -04:00
Greg Johnston
a9ed8461d1 feat: add "async routing" feature (#1055)
* add "async routing" feature that waits for async resources to resolve before navigating
* add support for Outlet
* add `<RoutingProgress/>` component
2023-05-21 06:46:23 -04:00
Vladimir Motylenko
5a71ca797a feat: RSX parser with recovery after errors, and unquoted text (#1054)
* Feat: Upgrade to new local version of syn-rsx

* chore: Make macro more IDE friendly

1. Add quotation to RawText node.
2. Replace vec! macro with [].to_vec().
Cons:
1. Temporary remove allow(unused_braces) from expressions, to allow completion after dot in rust-analyzer.

* chore: Change dependency from syn-rsx to rstml

* chore: Fix value_to_string usage, pr comments, and fmt.
2023-05-21 06:45:53 -04:00
agilarity
70eb07d7d6 test: setup e2e automatically (#1067) 2023-05-20 20:46:06 -04:00
Greg Johnston
71ee69af01 fix: avoid potential already-borrowed issues with resources nested in suspense 2023-05-20 20:42:06 -04:00
Ben Wishovich
dd41c0586c feat: allow specifying exact server function paths (#1069) 2023-05-19 16:47:28 -04:00
Greg Johnston
aaf63dbf5c docs: clarify SSR/WASM binary size comments (#1070) 2023-05-19 15:46:26 -04:00
Greg Johnston
87f6802967 docs: update notes on WASM binary size to work with SSR too (closes #1059) (#1068) 2023-05-19 15:08:32 -04:00
Greg Johnston
2cbf3581c5 fix: docs note on style refers to class (#1066) 2023-05-19 13:42:16 -04:00
agilarity
5a67e208fd test: verify tailwind example with playwright tests (#1062)
* chore: ignore playwright output

* fix: could not run playwright test

* test: should see the welcome message

* build: clean playwright output

* build: run playwright web tests

* build: setup e2e dependencies
2023-05-19 13:04:06 -04:00
Greg Johnston
3391a4a035 examples: fix todo_app_sqlite_axum (#1064) 2023-05-19 13:02:52 -04:00
Daniel Santana
076aa363a4 feat: added Debug, PartialEq and Eq derives to trigger. (#1060) 2023-05-18 20:32:25 -04:00
agilarity
2cb68c0bd4 fix: todomvc example style errors (#1058) 2023-05-18 15:49:34 -04:00
Greg Johnston
6eb24b5017 tests: fix broken SSR doctests (#1056) 2023-05-18 10:17:14 -04:00
yuuma03
b2faa6b86c feat: allow multipart forms on server fns (Actix) (#1048) 2023-05-17 19:53:55 -04:00
sjud
43990b5b67 docs: include link to book, Discord, examples (#1053) 2023-05-17 13:07:17 -04:00
kasbuunk
9453164dd2 docs: fix typo in view fn (#1050) 2023-05-16 14:34:37 -04:00
Greg Johnston
00fcd1c65e docs: fix small docs issues (closes #1045) (#1049) 2023-05-16 13:01:29 -04:00
Greg Johnston
85ad7b0f38 fix: <Suspense/> hydration when no resources are read under it (#1046) 2023-05-16 12:20:23 -04:00
Greg Johnston
f0a9940364 fix: leak in todomvc example (closes #706) 2023-05-15 14:53:39 -04:00
Mark Catley
b472aaf6a0 fix: typo in actix extract documentation (#1043) 2023-05-15 08:57:49 -04:00
56 changed files with 1057 additions and 524 deletions

View File

@@ -41,6 +41,14 @@ build-std = ["std", "panic_abort", "core", "alloc"]
build-std-features = ["panic_immediate_abort"]
```
Note that if you're using this with SSR too, the same Cargo profile will be applied. You'll need to explicitly specify your target:
```toml
[build]
target = "x86_64-unknown-linux-gnu" # or whatever
```
And you'll need to add `panic = "abort"` to `[profile.release]` in `Cargo.toml`. Note that this applies the same `build-std` and panic settings to your server binary, which may not be desirable. Some further exploration is probably needed here.
5. One of the sources of binary size in WASM binaries can be `serde` serialization/deserialization code. Leptos uses `serde` by default to serialize and deserialize resources created with `create_resource`. You might try experimenting with the `miniserde` and `serde-lite` features, which allow you to use those crates for serialization and deserialization instead; each only implements a subset of `serde`s functionality, but typically optimizes for size over speed.
## Things to Avoid

View File

@@ -90,7 +90,8 @@ view! { cx,
<button
// define an event listener with on:
on:click=move |_| {
set_count.update(|n| *n += 1);
// on stable, this is set_count.set(3);
set_count(3);
}
>
// text nodes are wrapped in quotation marks
@@ -142,6 +143,16 @@ in a function, telling the framework to update the view every time `count` chang
`{count()}` access the value of `count` once, and passes an `i32` into the view,
rendering it once, unreactively. You can see the difference in the CodeSandbox below!
Lets make one final change. `set_count(3)` is a pretty useless thing for a click handler to do. Lets replacing “set this value to 3” with “increment this value by 1”:
```rust
move |_| {
set_count.update(|n| *n += 1);
}
```
You can see here that while `set_count` just sets the value, `set_count.update()` gives us a mutable reference and mutates the value in place. Either one will trigger a reactive update in our UI.
> Throughout this tutorial, well use CodeSandbox to show interactive examples. To
> show the browser in the sandbox, you may need to click `Add DevTools >
Other Previews > 8080.` Hover over any of the variables to show Rust-Analyzer details

View File

@@ -15,7 +15,7 @@ You _could_ do this by just creating two `<progress>` elements:
let (count, set_count) = create_signal(cx, 0);
let double_count = move || count() * 2;
view! {
view! { cx,
<progress
max="50"
value=count

View File

@@ -0,0 +1,5 @@
[tasks.web-test]
dependencies = ["auto-setup", "cargo-leptos-e2e"]
[tasks.clean-all]
dependencies = ["clean-cargo", "clean-node_modules", "clean-playwright"]

View File

@@ -1,3 +1,6 @@
[env]
END2END_DIR = "end2end"
[tasks.pre-clippy]
env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
@@ -5,6 +8,9 @@ env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
description = "Check for style violations"
dependencies = ["check-format-flow", "clippy-flow"]
[tasks.check-format]
args = ["fmt", "--", "--check", "--config-path", "../../"]
[tasks.verify-local]
description = "Run all quality checks and tests from an example directory"
dependencies = ["check-style", "test-local"]
@@ -13,11 +19,69 @@ dependencies = ["check-style", "test-local"]
description = "Run all tests from an example directory"
dependencies = ["test", "web-test"]
[tasks.clean-cargo]
description = "Runs the cargo clean command."
category = "Cleanup"
command = "cargo"
args = ["clean"]
[tasks.clean-trunk]
description = "Runs the trunk clean command."
category = "Cleanup"
command = "trunk"
args = ["clean"]
[tasks.clean-node_modules]
description = "Delete all node_modules directories"
category = "Cleanup"
script = '''
find . -type d -name node_modules | xargs rm -rf
'''
[tasks.clean-playwright]
description = "Delete playwright directories"
category = "Cleanup"
cwd = "${END2END_DIR}"
command = "rm"
args = ["-rf", "playwright", "playwright/.cache", "test-results"]
[tasks.clean-all]
dependencies = ["clean", "clean-trunk"]
description = "Delete all temporary directories"
category = "Cleanup"
dependencies = ["clean-cargo"]
[tasks.wasm-web-test]
env = { CARGO_MAKE_WASM_TEST_ARGS = "--headless --chrome" }
command = "cargo"
args = ["make", "wasm-pack-test"]
[tasks.cargo-leptos-e2e]
description = "Runs end to end tests with cargo leptos"
command = "cargo"
args = ["leptos", "end-to-end"]
[tasks.setup]
description = "Setup e2e dependencies"
cwd = "${END2END_DIR}"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
RED="\e[0;31m"
RESET="\e[0m"
if command -v pnpm; then
pnpm install
elif command -v npm; then
npm install
else
echo "${RED}${BOLD}ERROR${RESET} - pnpm or npm is required by this task"
exit 1
fi
'''
[tasks.auto-setup]
script = '''
if [ ! -d "${END2END_DIR}/node_modules" ]; then
cargo make setup
fi
'''

View File

@@ -1,4 +1,5 @@
[tasks.web-test]
env = { CARGO_MAKE_WASM_TEST_ARGS = "--headless --chrome" }
command = "cargo"
args = ["make", "wasm-pack-test"]
dependencies = ["wasm-web-test"]
[tasks.clean-all]
dependencies = ["clean-cargo", "clean-trunk"]

View File

@@ -1,5 +1,5 @@
use cfg_if::cfg_if;
use leptos::{component, view, IntoView, Scope};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
mod api;
@@ -9,23 +9,25 @@ use routes::{nav::*, stories::*, story::*, users::*};
#[component]
pub fn App(cx: Scope) -> impl IntoView {
provide_meta_context(cx);
view! {
cx,
<>
<Stylesheet id="leptos" href="/pkg/hackernews.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />
<main>
<Routes>
<Route path="users/:id" view=|cx| view! { cx, <User/> }/>
<Route path="stories/:id" view=|cx| view! { cx, <Story/> }/>
<Route path=":stories?" view=|cx| view! { cx, <Stories/> }/>
</Routes>
</main>
</Router>
</>
let (is_routing, set_is_routing) = create_signal(cx, false);
view! { cx,
<Stylesheet id="leptos" href="/pkg/hackernews.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
// adding `set_is_routing` causes the router to wait for async data to load on new pages
<Router set_is_routing>
// shows a progress bar while async data are loading
<RoutingProgress is_routing max_time=std::time::Duration::from_millis(250)/>
<Nav />
<main>
<Routes>
<Route path="users/:id" view=|cx| view! { cx, <User/> }/>
<Route path="stories/:id" view=|cx| view! { cx, <Story/> }/>
<Route path=":stories?" view=|cx| view! { cx, <Stories/> }/>
</Routes>
</main>
</Router>
}
}

View File

@@ -8,3 +8,10 @@ Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# Support playwright testing
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/
pnpm-lock.yaml

View File

@@ -96,6 +96,7 @@ site-addr = "127.0.0.1:3000"
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head

View File

@@ -1,4 +1,7 @@
extend = [{ path = "../cargo-make/common.toml" }]
extend = [
{ path = "../cargo-make/common.toml" },
{ path = "../cargo-make/cargo-leptos-web-test.toml" },
]
[tasks.build]
command = "cargo"

View File

@@ -104,3 +104,8 @@ You'll need to install trunk to client side render this bundle.
## Attribution
Many thanks to GreatGreg for putting together this guide. You can find the original, with added details, [here](https://github.com/leptos-rs/leptos/discussions/125).
## Playwright Testing
- Run `cargo make setup` to install dependencies
- Run `cargo leptos test` or `cargo leptos end-to-end` to run the tests

View File

@@ -1,9 +1,7 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
test("should see the welcome message", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Cargo Leptos");
await expect(page.locator("h1")).toHaveText("Hi from your Leptos WASM!");
await expect(page.locator("h2")).toHaveText("Welcome to Leptos with Tailwind");
});

View File

@@ -146,11 +146,8 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
</header>
<main>
<Routes>
<Route path="" view=|cx| view! {
cx,
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
<Todos/>
</ErrorBoundary>
<Route path="" view=|cx| view! { cx,
<Todos/>
}/> //Route
<Route path="weird" methods=&[Method::Get, Method::Post]
ssr=SsrMode::Async
@@ -203,63 +200,65 @@ pub fn Todos(cx: Scope) -> impl IntoView {
<input type="submit" value="Add"/>
</MultiActionForm>
<Transition fallback=move || view! {cx, <p>"Loading..."</p> }>
{move || {
let existing_todos = {
move || {
todos.read(cx)
.map(move |todos| match todos {
Err(e) => {
view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view(cx)
}
Ok(todos) => {
if todos.is_empty() {
view! { cx, <p>"No tasks were found."</p> }.into_view(cx)
} else {
todos
.into_iter()
.map(move |todo| {
view! {
cx,
<li>
{todo.title}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
})
.collect_view(cx)
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
{move || {
let existing_todos = {
move || {
todos.read(cx)
.map(move |todos| match todos {
Err(e) => {
view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view(cx)
}
}
})
.unwrap_or_default()
}
};
let pending_todos = move || {
submissions
.get()
.into_iter()
.filter(|submission| submission.pending().get())
.map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
Ok(todos) => {
if todos.is_empty() {
view! { cx, <p>"No tasks were found."</p> }.into_view(cx)
} else {
todos
.into_iter()
.map(move |todo| {
view! {
cx,
<li>
{todo.title}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
})
.collect_view(cx)
}
}
})
.unwrap_or_default()
}
})
.collect_view(cx)
};
};
view! {
cx,
<ul>
{existing_todos}
{pending_todos}
</ul>
let pending_todos = move || {
submissions
.get()
.into_iter()
.filter(|submission| submission.pending().get())
.map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
}
})
.collect_view(cx)
};
view! {
cx,
<ul>
{existing_todos}
{pending_todos}
</ul>
}
}
}
}
</ErrorBoundary>
</Transition>
</div>
}

View File

@@ -43,7 +43,7 @@ impl Todos {
}
pub fn remove(&mut self, id: Uuid) {
self.0.retain(|todo| todo.id != id);
self.retain(|todo| todo.id != id);
}
pub fn remaining(&self) -> usize {
@@ -76,7 +76,23 @@ impl Todos {
}
fn clear_completed(&mut self) {
self.0.retain(|todo| !todo.completed.get());
self.retain(|todo| !todo.completed.get());
}
fn retain(&mut self, mut f: impl FnMut(&Todo) -> bool) {
self.0.retain(|todo| {
let retain = f(todo);
// because these signals are created at the top level,
// they are owned by the <TodoMVC/> component and not
// by the individual <Todo/> components. This means
// that if they are not manually disposed when removed, they
// will be held onto until the <TodoMVC/> is unmounted.
if !retain {
todo.title.dispose();
todo.completed.dispose();
}
retain
})
}
}
@@ -136,7 +152,7 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
// Handle the three filter modes: All, Active, and Completed
let (mode, set_mode) = create_signal(cx, Mode::All);
window_event_listener_untyped("hashchange", move |_| {
window_event_listener(ev::hashchange, move |_| {
let new_mode =
location_hash().map(|hash| route(&hash)).unwrap_or_default();
set_mode(new_mode);

View File

@@ -8,6 +8,7 @@ repository = "https://github.com/leptos-rs/leptos"
description = "Actix integrations for the Leptos web framework."
[dependencies]
actix-http = "3"
actix-web = "4"
futures = "0.3"
leptos = { workspace = true, features = ["ssr"] }

View File

@@ -185,7 +185,7 @@ pub fn handle_server_fns_with_context(
.and_then(|value| value.to_str().ok());
if let Some(server_fn) = server_fn_by_path(path.as_str()) {
let body: &[u8] = &body;
let body_ref: &[u8] = &body;
let runtime = create_runtime();
let (cx, disposer) = raw_scope_and_disposer(runtime);
@@ -198,10 +198,28 @@ pub fn handle_server_fns_with_context(
provide_context(cx, req.clone());
provide_context(cx, res_options.clone());
// we consume the body here (using the web::Bytes extractor), but it is required for things
// like MultipartForm
if req
.headers()
.get("Content-Type")
.and_then(|value| value.to_str().ok())
.and_then(|value| {
Some(
value.starts_with(
"multipart/form-data; boundary=",
),
)
})
== Some(true)
{
provide_context(cx, body.clone());
}
let query = req.query_string().as_bytes();
let data = match &server_fn.encoding {
Encoding::Url | Encoding::Cbor => body,
Encoding::Url | Encoding::Cbor => body_ref,
Encoding::GetJSON | Encoding::GetCBOR => query,
};
let res = match (server_fn.trait_obj)(cx, data).await {
@@ -1028,7 +1046,7 @@ where
}
}
/// A helper to make it easier to use Axum extractors in server functions. This takes
/// A helper to make it easier to use Actix extractors in server functions. This takes
/// a handler function as its argument. The handler follows similar rules to an Actix
/// [Handler](actix_web::Handler): it is an async function that receives arguments that
/// will be extracted from the request and returns some value.
@@ -1072,9 +1090,17 @@ where
{
let req = use_context::<actix_web::HttpRequest>(cx)
.expect("HttpRequest should have been provided via context");
let input = E::extract(&req)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
let input = if let Some(body) = use_context::<Bytes>(cx) {
let (_, mut payload) = actix_http::h1::Payload::create(false);
payload.unread_data(body);
E::from_request(&req, &mut dev::Payload::from(payload))
} else {
E::extract(&req)
}
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
Ok(f.call(input).await)
}

View File

@@ -12,6 +12,10 @@
//!
//! And you can do all three of these **using the same Leptos code.**
//!
//! Take a look at the [Leptos Book](https://leptos-rs.github.io/leptos/) for a walkthrough of the framework.
//! Join us on our [Discord Channel](https://discord.gg/v38Eef6sWG) to see what the community is building.
//! Explore our [Examples](https://github.com/leptos-rs/leptos/tree/main/examples) to see Leptos in action.
//!
//! # `nightly` Note
//! Most of the examples assume youre using `nightly` Rust. If youre on stable, note the following:
//! 1. You need to enable the `"stable"` flag in `Cargo.toml`: `leptos = { version = "0.0", features = ["stable"] }`

View File

@@ -101,13 +101,17 @@ where
use leptos_reactive::signal_prelude::*;
// run the child; we'll probably throw this away, but it will register resource reads
let child = orig_child(cx).into_view(cx);
let _child = orig_child(cx).into_view(cx);
let after_original_child = HydrationCtx::id();
let initial = {
// no resources were read under this, so just return the child
if context.pending_resources.get() == 0 {
child
let orig_child = Rc::clone(&orig_child);
HydrationCtx::continue_from(current_id.clone());
Fragment::lazy(Box::new(move || {
vec![DynChild::new(move || orig_child(cx)).into_view(cx)]
})).into_view(cx)
}
// show the fallback, but also prepare to stream HTML
else {

View File

@@ -14,16 +14,13 @@ fn simple_ssr_test() {
</div>
};
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-8|open--><div \
id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
assert!(rendered.into_view(cx).render_to_string(cx).contains(
"<div id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
id=\"_0-3\">Value: \
<!--hk=_0-4o|leptos-dyn-child-start-->0<!\
--hk=_0-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-8|close-->"
);
id=\"_0-5\">+1</button></div>"
));
});
}
@@ -54,28 +51,13 @@ fn ssr_test_with_components() {
</div>
};
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-49|open--><div id=\"_0-1\" \
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
assert!(rendered.into_view(cx).render_to_string(cx).contains(
"<div id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
id=\"_0-1-3\">Value: \
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-38|close--><!--hk=_0-1-0c|leptos-counter-end--><!\
--hk=_0-1-5-0o|leptos-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
id=\"_0-1-5-3\">Value: \
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5-5\">+1</button></div><!\
--leptos-view|leptos-tests-ssr.rs-38|close--><!\
--hk=_0-1-5-0c|leptos-counter-end--></div><!\
--leptos-view|leptos-tests-ssr.rs-49|close-->"
);
id=\"_0-1-5\">+1</button></div>"
));
});
}
@@ -106,29 +88,13 @@ fn ssr_test_with_snake_case_components() {
</div>
};
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-101|open--><div id=\"_0-1\" \
class=\"counters\"><!\
--hk=_0-1-0o|leptos-snake-case-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
assert!(rendered.into_view(cx).render_to_string(cx).contains(
"<div id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
id=\"_0-1-3\">Value: \
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-90|close--><!--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
id=\"_0-1-5-3\">Value: \
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5-5\">+1</button></div><!\
--leptos-view|leptos-tests-ssr.rs-90|close--><!\
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div><!\
--leptos-view|leptos-tests-ssr.rs-101|close-->"
);
id=\"_0-1-5\">+1</button></div>"
));
});
}
@@ -144,12 +110,10 @@ fn test_classes() {
<div class="my big" class:a={move || value.get() > 10} class:red=true class:car={move || value.get() > 1}></div>
};
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-142|open--><div id=\"_0-1\" \
class=\"my big red \
car\"></div><!--leptos-view|leptos-tests-ssr.rs-142|close-->"
);
assert!(rendered
.into_view(cx)
.render_to_string(cx)
.contains("<div id=\"_0-1\" class=\"my big red car\"></div>"));
});
}
@@ -168,13 +132,10 @@ fn ssr_with_styles() {
</div>
};
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-164|open--><div id=\"_0-1\" \
class=\" myclass\"><button id=\"_0-2\" class=\"btn \
myclass\">-1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-164|close-->"
);
assert!(rendered.into_view(cx).render_to_string(cx).contains(
"<div id=\"_0-1\" class=\" myclass\"><button id=\"_0-2\" \
class=\"btn myclass\">-1</button></div>"
));
});
}
@@ -190,11 +151,9 @@ fn ssr_option() {
<option/>
};
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-188|open--><option \
id=\"_0-1\"></option><!--leptos-view|leptos-tests-ssr.\
rs-188|close-->"
);
assert!(rendered
.into_view(cx)
.render_to_string(cx)
.contains("<option id=\"_0-1\"></option>"));
});
}

View File

@@ -17,7 +17,7 @@ leptos_actix = { path = "../../../../integrations/actix", optional = true }
leptos_router = { path = "../../../../router", default-features = false }
log = "0.4"
simple_logger = "4"
wasm-bindgen = "0.2"
wasm-bindgen = "0.2.85"
serde = "1.0.159"
tokio = { version = "1.27.0", features = ["time"], optional = true }

View File

@@ -56,6 +56,7 @@ pub fn App(cx: Scope) -> impl IntoView {
<Route path="single" view=|cx| view! { cx, <Single/> }/>
<Route path="parallel" view=|cx| view! { cx, <Parallel/> }/>
<Route path="inside-component" view=|cx| view! { cx, <InsideComponent/> }/>
<Route path="none" view=|cx| view! { cx, <None/> }/>
</Route>
// in-order
<Route
@@ -71,6 +72,7 @@ pub fn App(cx: Scope) -> impl IntoView {
<Route path="single" view=|cx| view! { cx, <Single/> }/>
<Route path="parallel" view=|cx| view! { cx, <Parallel/> }/>
<Route path="inside-component" view=|cx| view! { cx, <InsideComponent/> }/>
<Route path="none" view=|cx| view! { cx, <None/> }/>
</Route>
// async
<Route
@@ -86,6 +88,7 @@ pub fn App(cx: Scope) -> impl IntoView {
<Route path="single" view=|cx| view! { cx, <Single/> }/>
<Route path="parallel" view=|cx| view! { cx, <Parallel/> }/>
<Route path="inside-component" view=|cx| view! { cx, <InsideComponent/> }/>
<Route path="none" view=|cx| view! { cx, <None/> }/>
</Route>
</Routes>
</main>
@@ -101,6 +104,7 @@ fn SecondaryNav(cx: Scope) -> impl IntoView {
<A href="single">"Single"</A>
<A href="parallel">"Parallel"</A>
<A href="inside-component">"Inside Component"</A>
<A href="none">"No Resources"</A>
</nav>
}
}
@@ -217,3 +221,25 @@ fn InsideComponentChild(cx: Scope) -> impl IntoView {
</Suspense>
}
}
#[component]
fn None(cx: Scope) -> impl IntoView {
let (count, set_count) = create_signal(cx, 0);
view! { cx,
<div>
<Suspense fallback=|| "Loading 1...">
<div>"Children inside Suspense should hydrate properly."</div>
<button on:click=move |_| set_count.update(|n| *n += 1)>
{count}
</button>
</Suspense>
<p>"Children following " <code>"<Suspense/>"</code> " should hydrate properly."</p>
<div>
<button on:click=move |_| set_count.update(|n| *n += 1)>
{count}
</button>
</div>
</div>
}
}

View File

@@ -278,7 +278,33 @@ where
let start = child.get_opening_node();
let end = &closing;
unmount_child(&start, end);
match child {
View::CoreComponent(
crate::CoreComponent::DynChild(
child,
),
) => {
let start =
child.get_opening_node();
let end = child.closing.node;
prepare_to_move(
&child.document_fragment,
&start,
&end,
);
}
View::Component(child) => {
let start =
child.get_opening_node();
let end = child.closing.node;
prepare_to_move(
&child.document_fragment,
&start,
&end,
);
}
_ => unmount_child(&start, end),
}
}
// Mount the new child

View File

@@ -168,7 +168,7 @@ pub(crate) struct EachItem {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
document_fragment: Option<web_sys::DocumentFragment>,
#[cfg(debug_assertions)]
opening: Comment,
opening: Option<Comment>,
pub(crate) child: View,
closing: Option<Comment>,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
@@ -201,7 +201,11 @@ impl EachItem {
None
},
#[cfg(debug_assertions)]
Comment::new(Cow::Borrowed("<EachItem>"), &id, false),
if needs_closing {
Some(Comment::new(Cow::Borrowed("<EachItem>"), &id, false))
} else {
None
},
);
#[cfg(all(target_arch = "wasm32", feature = "web"))]
@@ -215,7 +219,10 @@ impl EachItem {
if !HydrationCtx::is_hydrating() {
#[cfg(debug_assertions)]
fragment
.append_with_node_2(&markers.1.node, &closing.node)
.append_with_node_2(
&markers.1.as_ref().unwrap().node,
&closing.node,
)
.unwrap();
fragment.append_with_node_1(&closing.node).unwrap();
}
@@ -260,10 +267,6 @@ impl Mountable for EachItem {
#[inline(always)]
fn get_opening_node(&self) -> web_sys::Node {
#[cfg(debug_assertions)]
return self.opening.node.clone();
#[cfg(not(debug_assertions))]
return self.child.get_opening_node();
}
@@ -673,10 +676,20 @@ fn apply_cmds<T, EF, N>(
// 4. Add
if cmds.clear {
cmds.removed.clear();
crate::log!("clearing list");
web_sys::console::log_2(
&wasm_bindgen::JsValue::from_str("open"),
opening,
);
web_sys::console::log_2(
&wasm_bindgen::JsValue::from_str("closing"),
closing,
);
if opening.previous_sibling().is_none()
&& closing.next_sibling().is_none()
{
crate::log!("no siblings");
let parent = closing
.parent_node()
.expect("could not get closing node")
@@ -689,6 +702,7 @@ fn apply_cmds<T, EF, N>(
#[cfg(not(debug_assertions))]
parent.append_with_node_1(closing).unwrap();
} else {
crate::log!("yes siblings");
range.set_start_before(opening).unwrap();
range.set_end_before(closing).unwrap();

View File

@@ -29,7 +29,7 @@ pub trait EventDescriptor: Clone {
}
}
/// Overrides the [`EventDescriptor::bubbles`] method to always return
/// Overrides the [`EventDescriptor::BUBBLES`] value to always return
/// `false`, which forces the event to not be globally delegated.
#[derive(Clone)]
#[allow(non_camel_case_types)]

View File

@@ -807,7 +807,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
/// Sets a style on an element.
///
/// **Note**: In the builder syntax, this will be overwritten by the `style`
/// attribute if you use `.attr("class", /* */)`. In the `view` macro, they
/// attribute if you use `.attr("style", /* */)`. In the `view` macro, they
/// are automatically re-ordered so that this over-writing does not happen.
#[track_caller]
pub fn style(

View File

@@ -240,13 +240,20 @@ impl View {
dont_escape_text: bool,
) {
match self {
View::Suspense(id, _) => {
View::Suspense(id, view) => {
let id = id.to_string();
if let Some(data) = cx.take_pending_fragment(&id) {
chunks.push_back(StreamChunk::Async {
chunks: data.in_order,
should_block: data.should_block,
});
} else {
// if not registered, means it was already resolved
View::CoreComponent(view).into_stream_chunks_helper(
cx,
chunks,
dont_escape_text,
);
}
}
View::Text(node) => {

View File

@@ -11,7 +11,7 @@ readme = "../README.md"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
syn = { version = "1", features = [
syn = { version = "2", features = [
"full",
"parsing",
"extra-traits",
@@ -19,7 +19,7 @@ syn = { version = "1", features = [
"printing",
] }
quote = "1"
syn-rsx = "0.9"
rstml = "0.10.6"
proc-macro2 = { version = "1", features = ["span-locations", "nightly"] }
parking_lot = "0.12"
walkdir = "2"

View File

@@ -76,7 +76,7 @@ impl ViewMacros {
tokens.next(); // ,
// TODO handle class = ...
let rsx =
syn_rsx::parse2(tokens.collect::<proc_macro2::TokenStream>())?;
rstml::parse2(tokens.collect::<proc_macro2::TokenStream>())?;
let template = LNode::parse_view(rsx)?;
views.push(MacroInvocation { id, template })
}

View File

@@ -1,8 +1,8 @@
use crate::parsing::{is_component_node, value_to_string};
use crate::parsing::is_component_node;
use anyhow::Result;
use quote::quote;
use quote::ToTokens;
use rstml::node::{Node, NodeAttribute};
use serde::{Deserialize, Serialize};
use syn_rsx::Node;
// A lightweight virtual DOM structure we can use to hold
// the state of a Leptos view macro template. This is because
@@ -58,36 +58,30 @@ impl LNode {
}
}
Node::Text(text) => {
if let Some(value) = value_to_string(&text.value) {
views.push(LNode::Text(value));
} else {
let value = text.value.as_ref();
let code = quote! { #value };
let code = code.to_string();
views.push(LNode::DynChild(code));
}
views.push(LNode::Text(text.value_string()));
}
Node::Block(block) => {
let value = block.value.as_ref();
let code = quote! { #value };
let code = block.into_token_stream();
let code = code.to_string();
views.push(LNode::DynChild(code));
}
Node::Element(el) => {
if is_component_node(&el) {
let name = el.name().to_string();
let mut children = Vec::new();
for child in el.children {
LNode::parse_node(child, &mut children)?;
}
views.push(LNode::Component {
name: el.name.to_string(),
name: name,
props: el
.open_tag
.attributes
.into_iter()
.filter_map(|attr| match attr {
Node::Attribute(attr) => Some((
NodeAttribute::Attribute(attr) => Some((
attr.key.to_string(),
format!("{:#?}", attr.value),
format!("{:#?}", attr.value()),
)),
_ => None,
})
@@ -95,15 +89,13 @@ impl LNode {
children,
});
} else {
let name = el.name.to_string();
let name = el.name().to_string();
let mut attrs = Vec::new();
for attr in el.attributes {
if let Node::Attribute(attr) = attr {
for attr in el.open_tag.attributes {
if let NodeAttribute::Attribute(attr) = attr {
let name = attr.key.to_string();
if let Some(value) =
attr.value.as_ref().and_then(value_to_string)
{
if let Some(value) = attr.value_literal_string() {
attrs.push((
name,
LAttributeValue::Static(value),

View File

@@ -1,7 +1,37 @@
use syn_rsx::{NodeElement, NodeValueExpr};
use rstml::node::NodeElement;
pub fn value_to_string(value: &NodeValueExpr) -> Option<String> {
match &value.as_ref() {
///
/// Converts `syn::Block` to simple expression
///
/// For example:
/// ```no_build
/// // "string literal" in
/// {"string literal"}
/// // number literal
/// {0x12}
/// // boolean literal
/// {true}
/// // variable
/// {path::x}
/// ```
pub fn block_to_primitive_expression(block: &syn::Block) -> Option<&syn::Expr> {
// its empty block, or block with multi lines
if block.stmts.len() != 1 {
return None;
}
match &block.stmts[0] {
syn::Stmt::Expr(e, None) => return Some(&e),
_ => {}
}
None
}
/// Converts simple literals to its string representation.
///
/// This function doesn't convert literal wrapped inside block
/// like: `{"string"}`.
pub fn value_to_string(value: &syn::Expr) -> Option<String> {
match &value {
syn::Expr::Lit(lit) => match &lit.lit {
syn::Lit::Str(s) => Some(s.value()),
syn::Lit::Char(c) => Some(c.value().to_string()),
@@ -14,7 +44,7 @@ pub fn value_to_string(value: &NodeValueExpr) -> Option<String> {
}
pub fn is_component_node(node: &NodeElement) -> bool {
node.name
node.name()
.to_string()
.starts_with(|c: char| c.is_ascii_uppercase())
}

View File

@@ -12,16 +12,16 @@ readme = "../README.md"
proc-macro = true
[dependencies]
attribute-derive = { version = "0.5", features = ["syn-full"] }
attribute-derive = { version = "0.6", features = ["syn-full"] }
cfg-if = "1"
html-escape = "0.2"
itertools = "0.10"
prettyplease = "0.1"
prettyplease = "0.2.4"
proc-macro-error = "1"
proc-macro2 = "1"
quote = "1"
syn = { version = "1", features = ["full"] }
syn-rsx = "0.9"
syn = { version = "2", features = ["full"] }
rstml = "0.10.6"
leptos_hot_reload = { workspace = true }
server_fn_macro = { workspace = true }
convert_case = "0.6.0"

View File

@@ -4,15 +4,15 @@ use convert_case::{
Casing,
};
use itertools::Itertools;
use leptos_hot_reload::parsing::value_to_string;
use proc_macro2::{Ident, Span, TokenStream};
use quote::{format_ident, quote_spanned, ToTokens, TokenStreamExt};
use syn::{
parse::Parse, parse_quote, spanned::Spanned,
AngleBracketedGenericArguments, Attribute, FnArg, GenericArgument, Item,
ItemFn, Lit, LitStr, Meta, MetaNameValue, Pat, PatIdent, Path,
PathArguments, ReturnType, Stmt, Type, TypePath, Visibility,
ItemFn, LitStr, Meta, Pat, PatIdent, Path, PathArguments, ReturnType, Stmt,
Type, TypePath, Visibility,
};
pub struct Model {
is_transparent: bool,
docs: Docs,
@@ -56,14 +56,17 @@ impl Parse for Model {
// We need to remove the `#[doc = ""]` and `#[builder(_)]`
// attrs from the function signature
drain_filter(&mut item.attrs, |attr| {
attr.path == parse_quote!(doc) || attr.path == parse_quote!(prop)
drain_filter(&mut item.attrs, |attr| match &attr.meta {
Meta::NameValue(attr) => attr.path == parse_quote!(doc),
Meta::List(attr) => attr.path == parse_quote!(prop),
_ => false,
});
item.sig.inputs.iter_mut().for_each(|arg| {
if let FnArg::Typed(ty) = arg {
drain_filter(&mut ty.attrs, |attr| {
attr.path == parse_quote!(doc)
|| attr.path == parse_quote!(prop)
drain_filter(&mut ty.attrs, |attr| match &attr.meta {
Meta::NameValue(attr) => attr.path == parse_quote!(doc),
Meta::List(attr) => attr.path == parse_quote!(prop),
_ => false,
});
}
});
@@ -400,12 +403,20 @@ impl Docs {
let mut attrs = attrs
.iter()
.filter_map(|attr| attr.path.is_ident("doc").then(|| {
let Ok(Meta::NameValue(MetaNameValue { lit: Lit::Str(doc), .. })) = attr.parse_meta() else {
abort!(attr, "expected doc comment to be string literal");
.filter_map(|attr| {
let Meta::NameValue(attr ) = &attr.meta else {
return None
};
(doc.value(), doc.span())
}))
if !attr.path.is_ident("doc") {
return None
}
let Some(val) = value_to_string(&attr.value) else {
abort!(attr, "expected string literal in value of doc comment");
};
Some((val, attr.path.span()))
})
.flat_map(map)
.collect_vec();

View File

@@ -7,9 +7,9 @@ extern crate proc_macro_error;
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenTree};
use quote::ToTokens;
use rstml::{node::KeyedAttribute, parse};
use server_fn_macro::{server_macro_impl, ServerContext};
use syn::parse_macro_input;
use syn_rsx::{parse, NodeAttribute};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
@@ -351,16 +351,22 @@ pub fn view(tokens: TokenStream) -> TokenStream {
.chain(tokens)
.collect()
};
match parse(tokens.into()) {
Ok(nodes) => render_view(
&proc_macro2::Ident::new(&cx.to_string(), cx.span()),
&nodes,
Mode::default(),
global_class.as_ref(),
normalized_call_site(proc_macro::Span::call_site()),
),
Err(error) => error.to_compile_error(),
let config = rstml::ParserConfig::default().recover_block(true);
let parser = rstml::Parser::new(config);
let (nodes, errors) = parser.parse_recoverable(tokens).split_vec();
let errors = errors.into_iter().map(|e| e.emit_as_expr_tokens());
let nodes_output = render_view(
&cx,
&nodes,
Mode::default(),
global_class.as_ref(),
normalized_call_site(proc_macro::Span::call_site()),
);
quote! {
{
#(#errors;)*
#nodes_output
}
}
.into()
}
@@ -672,7 +678,7 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// Annotates a struct so that it can be used with your Component as a `slot`.
///
/// The `#[slot]` macro allows you to annotate plain Rust struct as component slots and use them
/// within your Leptos [component](crate::component!) properties. The struct can contain any number
/// within your Leptos [`component`](macro@crate::component) properties. The struct can contain any number
/// of fields. When you use the component somewhere else, the names of the slot fields are the
/// names of the properties you use in the [view](crate::view!) macro.
///
@@ -874,9 +880,9 @@ pub fn params_derive(
}
}
pub(crate) fn attribute_value(attr: &NodeAttribute) -> &syn::Expr {
match &attr.value {
Some(value) => value.as_ref(),
pub(crate) fn attribute_value(attr: &KeyedAttribute) -> &syn::Expr {
match &attr.possible_value {
Some(value) => &value.value,
None => abort!(attr.key, "attribute should have value"),
}
}

View File

@@ -5,7 +5,8 @@ use attribute_derive::Attribute as AttributeDerive;
use proc_macro2::{Ident, TokenStream};
use quote::{ToTokens, TokenStreamExt};
use syn::{
parse::Parse, parse_quote, Field, ItemStruct, LitStr, Type, Visibility,
parse::Parse, parse_quote, Field, ItemStruct, LitStr, Meta, Type,
Visibility,
};
pub struct Model {
@@ -31,13 +32,16 @@ impl Parse for Model {
// We need to remove the `#[doc = ""]` and `#[builder(_)]`
// attrs from the function signature
drain_filter(&mut item.attrs, |attr| {
attr.path == parse_quote!(doc) || attr.path == parse_quote!(prop)
drain_filter(&mut item.attrs, |attr| match &attr.meta {
Meta::NameValue(attr) => attr.path == parse_quote!(doc),
Meta::List(attr) => attr.path == parse_quote!(prop),
_ => false,
});
item.fields.iter_mut().for_each(|arg| {
drain_filter(&mut arg.attrs, |attr| {
attr.path == parse_quote!(doc)
|| attr.path == parse_quote!(prop)
drain_filter(&mut arg.attrs, |attr| match &attr.meta {
Meta::NameValue(attr) => attr.path == parse_quote!(doc),
Meta::List(attr) => attr.path == parse_quote!(prop),
_ => false,
});
});

View File

@@ -1,9 +1,14 @@
use crate::attribute_value;
use leptos_hot_reload::parsing::is_component_node;
use itertools::Either;
use leptos_hot_reload::parsing::{
block_to_primitive_expression, is_component_node, value_to_string,
};
use proc_macro2::{Ident, Span, TokenStream};
use quote::{quote, quote_spanned};
use quote::{quote, quote_spanned, ToTokens};
use rstml::node::{
KeyedAttribute, Node, NodeAttribute, NodeBlock, NodeElement,
};
use syn::spanned::Spanned;
use syn_rsx::{Node, NodeAttribute, NodeElement, NodeValueExpr};
use uuid::Uuid;
pub(crate) fn render_template(cx: &Ident, nodes: &[Node]) -> TokenStream {
@@ -53,7 +58,7 @@ fn root_element_to_tokens(
.unwrap();
};
let span = node.name.span();
let span = node.name().span();
let navigations = if navigations.is_empty() {
quote! {}
@@ -67,7 +72,7 @@ fn root_element_to_tokens(
quote! { #(#expressions;);* }
};
let tag_name = node.name.to_string();
let tag_name = node.name().to_string();
quote_spanned! {
span => {
@@ -104,9 +109,9 @@ enum PrevSibChange {
Skip,
}
fn attributes(node: &NodeElement) -> impl Iterator<Item = &NodeAttribute> {
node.attributes.iter().filter_map(|node| {
if let Node::Attribute(attribute) = node {
fn attributes(node: &NodeElement) -> impl Iterator<Item = &KeyedAttribute> {
node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(attribute) = node {
Some(attribute)
} else {
None
@@ -129,11 +134,11 @@ fn element_to_tokens(
) -> Ident {
// create this element
*next_el_id += 1;
let this_el_ident = child_ident(*next_el_id, node.name.span());
let this_el_ident = child_ident(*next_el_id, node.name().span());
// Open tag
let name_str = node.name.to_string();
let span = node.name.span();
let name_str = node.name().to_string();
let span = node.name().span();
// CSR/hydrate, push to template
template.push('<');
@@ -145,7 +150,7 @@ fn element_to_tokens(
}
// navigation for this el
let debug_name = node.name.to_string();
let debug_name = node.name().to_string();
let this_nav = if is_root_el {
quote_spanned! {
span => let #this_el_ident = #debug_name;
@@ -247,14 +252,17 @@ fn next_sibling_node(
if is_component_node(sibling) {
next_sibling_node(children, idx + 1, next_el_id)
} else {
Ok(Some(child_ident(*next_el_id + 1, sibling.name.span())))
Ok(Some(child_ident(
*next_el_id + 1,
sibling.name().span(),
)))
}
}
Node::Block(sibling) => {
Ok(Some(child_ident(*next_el_id + 1, sibling.value.span())))
Ok(Some(child_ident(*next_el_id + 1, sibling.span())))
}
Node::Text(sibling) => {
Ok(Some(child_ident(*next_el_id + 1, sibling.value.span())))
Ok(Some(child_ident(*next_el_id + 1, sibling.span())))
}
_ => Err("expected either an element or a block".to_string()),
}
@@ -263,7 +271,7 @@ fn next_sibling_node(
fn attr_to_tokens(
cx: &Ident,
node: &NodeAttribute,
node: &KeyedAttribute,
el_id: &Ident,
template: &mut String,
expressions: &mut Vec<TokenStream>,
@@ -272,8 +280,8 @@ fn attr_to_tokens(
let name = name.strip_prefix('_').unwrap_or(&name);
let name = name.strip_prefix("attr:").unwrap_or(name);
let value = match &node.value {
Some(expr) => match expr.as_ref() {
let value = match &node.value() {
Some(expr) => match expr {
syn::Expr::Lit(expr_lit) => {
if let syn::Lit::Str(s) = &expr_lit.lit {
AttributeValue::Static(s.value())
@@ -367,7 +375,7 @@ fn child_to_tokens(
Node::Element(node) => {
if is_component_node(node) {
proc_macro_error::emit_error!(
node.name.span(),
node.name().span(),
"component children not allowed in template!, use view! \
instead"
);
@@ -389,7 +397,7 @@ fn child_to_tokens(
}
Node::Text(node) => block_to_tokens(
cx,
&node.value,
Either::Left(node.value_string()),
node.value.span(),
parent,
prev_sib,
@@ -399,10 +407,42 @@ fn child_to_tokens(
expressions,
navigations,
),
Node::Block(node) => block_to_tokens(
Node::RawText(node) => block_to_tokens(
cx,
&node.value,
node.value.span(),
Either::Left(node.to_string_best()),
node.span(),
parent,
prev_sib,
next_sib,
next_el_id,
template,
expressions,
navigations,
),
Node::Block(NodeBlock::ValidBlock(b)) => {
let value = match block_to_primitive_expression(b)
.and_then(value_to_string)
{
Some(v) => Either::Left(v),
None => Either::Right(b.into_token_stream()),
};
block_to_tokens(
cx,
value,
b.span(),
parent,
prev_sib,
next_sib,
next_el_id,
template,
expressions,
navigations,
)
}
Node::Block(b @ NodeBlock::Invalid { .. }) => block_to_tokens(
cx,
Either::Right(b.into_token_stream()),
b.span(),
parent,
prev_sib,
next_sib,
@@ -418,7 +458,7 @@ fn child_to_tokens(
#[allow(clippy::too_many_arguments)]
fn block_to_tokens(
_cx: &Ident,
value: &NodeValueExpr,
value: Either<String, TokenStream>,
span: Span,
parent: &Ident,
prev_sib: Option<Ident>,
@@ -428,18 +468,6 @@ fn block_to_tokens(
expressions: &mut Vec<TokenStream>,
navigations: &mut Vec<TokenStream>,
) -> PrevSibChange {
let value = value.as_ref();
let str_value = match value {
syn::Expr::Lit(lit) => match &lit.lit {
syn::Lit::Str(s) => Some(s.value()),
syn::Lit::Char(c) => Some(c.value().to_string()),
syn::Lit::Int(i) => Some(i.base10_digits().to_string()),
syn::Lit::Float(f) => Some(f.base10_digits().to_string()),
_ => None,
},
_ => None,
};
// code to navigate to this text node
let (name, location) = /* if is_first_child && mode == Mode::Client {
@@ -473,27 +501,30 @@ fn block_to_tokens(
}
};
if let Some(v) = str_value {
navigations.push(location);
template.push_str(&v);
match value {
Either::Left(v) => {
navigations.push(location);
template.push_str(&v);
if let Some(name) = name {
PrevSibChange::Sib(name)
} else {
PrevSibChange::Parent
if let Some(name) = name {
PrevSibChange::Sib(name)
} else {
PrevSibChange::Parent
}
}
} else {
template.push_str("<!>");
navigations.push(location);
Either::Right(value) => {
template.push_str("<!>");
navigations.push(location);
expressions.push(quote! {
leptos::leptos_dom::mount_child(#mount_kind, &{#value}.into_view(cx));
});
expressions.push(quote! {
leptos::leptos_dom::mount_child(#mount_kind, &{#value}.into_view(cx));
});
if let Some(name) = name {
PrevSibChange::Sib(name)
} else {
PrevSibChange::Parent
if let Some(name) = name {
PrevSibChange::Sib(name)
} else {
PrevSibChange::Parent
}
}
}
}

View File

@@ -1,11 +1,15 @@
use crate::{attribute_value, Mode};
use convert_case::{Case::Snake, Casing};
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
use leptos_hot_reload::parsing::{
block_to_primitive_expression, is_component_node, value_to_string,
};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
use rstml::node::{
KeyedAttribute, Node, NodeAttribute, NodeBlock, NodeElement, NodeName,
};
use std::collections::HashMap;
use syn::{spanned::Spanned, Expr, ExprLit, ExprPath, Lit};
use syn_rsx::{Node, NodeAttribute, NodeElement, NodeName, NodeValueExpr};
#[derive(Clone, Copy)]
enum TagType {
@@ -213,18 +217,22 @@ fn root_node_to_tokens_ssr(
global_class,
view_marker,
),
Node::Comment(_) | Node::Doctype(_) | Node::Attribute(_) => quote! {},
Node::Comment(_) | Node::Doctype(_) => quote! {},
Node::Text(node) => {
let value = node.value.as_ref();
quote! {
leptos::leptos_dom::html::text(#value)
leptos::leptos_dom::html::text(#node)
}
}
Node::RawText(r) => {
let text = r.to_string_best();
let text = syn::LitStr::new(&text, r.span());
quote! {
leptos::leptos_dom::html::text(#text)
}
}
Node::Block(node) => {
let value = node.value.as_ref();
quote! {
#[allow(unused_braces)]
#value
#node
}
}
Node::Element(node) => {
@@ -254,9 +262,9 @@ fn fragment_to_tokens_ssr(
});
quote! {
{
leptos::Fragment::lazy(|| vec![
leptos::Fragment::lazy(|| [
#(#nodes),*
])
].to_vec())
#view_marker
}
}
@@ -329,15 +337,15 @@ fn root_element_to_tokens_ssr(
},
});
let tag_name = node.name.to_string();
let tag_name = node.name().to_string();
let is_custom_element = is_custom_element(&tag_name);
let typed_element_name = if is_custom_element {
Ident::new("Custom", node.name.span())
Ident::new("Custom", node.name().span())
} else {
let camel_cased = camel_case_tag_name(
&tag_name.replace("svg::", "").replace("math::", ""),
);
Ident::new(&camel_cased, node.name.span())
Ident::new(&camel_cased, node.name().span())
};
let typed_element_name = if is_svg_element(&tag_name) {
quote! { svg::#typed_element_name }
@@ -409,7 +417,7 @@ fn element_to_tokens_ssr(
}));
} else {
let tag_name = node
.name
.name()
.to_string()
.replace("svg::", "")
.replace("math::", "");
@@ -419,8 +427,8 @@ fn element_to_tokens_ssr(
let mut inner_html = None;
for attr in &node.attributes {
if let Node::Attribute(attr) = attr {
for attr in node.attributes() {
if let NodeAttribute::Attribute(attr) = attr {
inner_html = attribute_to_tokens_ssr(
cx,
attr,
@@ -439,9 +447,9 @@ fn element_to_tokens_ssr(
quote! { leptos::leptos_dom::HydrationCtx::id() }
};
match node
.attributes
.attributes()
.iter()
.find(|node| matches!(node, Node::Attribute(attr) if attr.key.to_string() == "id"))
.find(|node| matches!(node, NodeAttribute::Attribute(attr) if attr.key.to_string() == "id"))
{
Some(_) => {
template.push_str(" leptos-hk=\"_{}\"");
@@ -462,7 +470,7 @@ fn element_to_tokens_ssr(
if let Some(inner_html) = inner_html {
template.push_str("{}");
let value = inner_html.as_ref();
let value = inner_html;
holes.push(quote! {
(#value).into_attribute(#cx).as_nameless_value_string().unwrap_or_default()
@@ -484,32 +492,23 @@ fn element_to_tokens_ssr(
);
}
Node::Text(text) => {
if let Some(value) = value_to_string(&text.value) {
let value = if is_script_or_style {
value.into()
} else {
html_escape::encode_safe(&value)
};
template.push_str(
&value
.replace('{', "\\{")
.replace('}', "\\}"),
);
let value = text.value_string();
let value = if is_script_or_style {
value.into()
} else {
template.push_str("{}");
let value = text.value.as_ref();
holes.push(quote! {
#value.into_view(#cx).render_to_string(#cx)
})
}
html_escape::encode_safe(&value)
};
template.push_str(
&value.replace('{', "\\{").replace('}', "\\}"),
);
}
Node::Block(block) => {
if let Some(value) = value_to_string(&block.value) {
Node::Block(NodeBlock::ValidBlock(block)) => {
if let Some(value) =
block_to_primitive_expression(block)
.and_then(value_to_string)
{
template.push_str(&value);
} else {
let value = block.value.as_ref();
if !template.is_empty() {
chunks.push(SsrElementChunks::String {
template: std::mem::take(template),
@@ -517,10 +516,16 @@ fn element_to_tokens_ssr(
})
}
chunks.push(SsrElementChunks::View(quote! {
{#value}.into_view(#cx)
{#block}.into_view(#cx)
}));
}
}
// Keep invalid blocks for faster IDE diff (on user type)
Node::Block(block @ NodeBlock::Invalid { .. }) => {
chunks.push(SsrElementChunks::View(quote! {
{#block}.into_view(#cx)
}));
}
Node::Fragment(_) => abort!(
Span::call_site(),
"You can't nest a fragment inside an element."
@@ -531,7 +536,7 @@ fn element_to_tokens_ssr(
}
template.push_str("</");
template.push_str(&node.name.to_string());
template.push_str(&node.name().to_string());
template.push('>');
}
}
@@ -540,17 +545,17 @@ fn element_to_tokens_ssr(
// returns `inner_html`
fn attribute_to_tokens_ssr<'a>(
cx: &Ident,
node: &'a NodeAttribute,
attr: &'a KeyedAttribute,
template: &mut String,
holes: &mut Vec<TokenStream>,
exprs_for_compiler: &mut Vec<TokenStream>,
global_class: Option<&TokenTree>,
) -> Option<&'a NodeValueExpr> {
let name = node.key.to_string();
) -> Option<&'a syn::Expr> {
let name = attr.key.to_string();
if name == "ref" || name == "_ref" || name == "ref_" || name == "node_ref" {
// ignore refs on SSR
} else if let Some(name) = name.strip_prefix("on:") {
let handler = attribute_value(node);
let handler = attribute_value(attr);
let (event_type, _, _) = parse_event_name(name);
exprs_for_compiler.push(quote! {
@@ -563,16 +568,16 @@ fn attribute_to_tokens_ssr<'a>(
// ignore props for SSR
// ignore classes and sdtyles: we'll handle these separately
} else if name == "inner_html" {
return node.value.as_ref();
return attr.value();
} else {
let name = name.replacen("attr:", "", 1);
// special case of global_class and class attribute
if name == "class"
&& global_class.is_some()
&& node.value.as_ref().and_then(value_to_string).is_none()
&& attr.value().and_then(value_to_string).is_none()
{
let span = node.key.span();
let span = attr.key.span();
proc_macro_error::emit_error!(span, "Combining a global class (view! { cx, class = ... }) \
and a dynamic `class=` attribute on an element causes runtime inconsistencies. You can \
toggle individual classes dynamically with the `class:name=value` syntax. \n\nSee this issue \
@@ -582,7 +587,7 @@ fn attribute_to_tokens_ssr<'a>(
if name != "class" && name != "style" {
template.push(' ');
if let Some(value) = node.value.as_ref() {
if let Some(value) = attr.value() {
if let Some(value) = value_to_string(value) {
template.push_str(&name);
template.push_str("=\"");
@@ -590,7 +595,6 @@ fn attribute_to_tokens_ssr<'a>(
template.push('"');
} else {
template.push_str("{}");
let value = value.as_ref();
holes.push(quote! {
&{#value}.into_attribute(#cx)
.as_nameless_value_string()
@@ -630,11 +634,13 @@ fn set_class_attribute_ssr(
Some(val) => (String::new(), Some(val)),
};
let static_class_attr = node
.attributes
.attributes()
.iter()
.filter_map(|a| match a {
Node::Attribute(attr) if attr.key.to_string() == "class" => {
attr.value.as_ref().and_then(value_to_string)
NodeAttribute::Attribute(attr)
if attr.key.to_string() == "class" =>
{
attr.value().and_then(value_to_string)
}
_ => None,
})
@@ -644,17 +650,17 @@ fn set_class_attribute_ssr(
.join(" ");
let dyn_class_attr = node
.attributes
.attributes()
.iter()
.filter_map(|a| {
if let Node::Attribute(a) = a {
if let NodeAttribute::Attribute(a) = a {
if a.key.to_string() == "class" {
if a.value.as_ref().and_then(value_to_string).is_some()
if a.value().and_then(value_to_string).is_some()
|| fancy_class_name(&a.key.to_string(), cx, a).is_some()
{
None
} else {
Some((a.key.span(), &a.value))
Some((a.key.span(), a.value()))
}
} else {
None
@@ -666,10 +672,10 @@ fn set_class_attribute_ssr(
.collect::<Vec<_>>();
let class_attrs = node
.attributes
.attributes()
.iter()
.filter_map(|node| {
if let Node::Attribute(node) = node {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
if name == "class" {
return if let Some((_, name, value)) =
@@ -713,7 +719,6 @@ fn set_class_attribute_ssr(
for (_span, value) in dyn_class_attr {
if let Some(value) = value {
template.push_str(" {}");
let value = value.as_ref();
holes.push(quote! {
&(#cx, #value).into_attribute(#cx).as_nameless_value_string()
.map(|a| leptos::leptos_dom::ssr::escape_attr(&a).to_string())
@@ -745,11 +750,13 @@ fn set_style_attribute_ssr(
holes: &mut Vec<TokenStream>,
) {
let static_style_attr = node
.attributes
.attributes()
.iter()
.filter_map(|a| match a {
Node::Attribute(attr) if attr.key.to_string() == "style" => {
attr.value.as_ref().and_then(value_to_string)
NodeAttribute::Attribute(attr)
if attr.key.to_string() == "style" =>
{
attr.value().and_then(value_to_string)
}
_ => None,
})
@@ -757,17 +764,17 @@ fn set_style_attribute_ssr(
.map(|style| format!("{style};"));
let dyn_style_attr = node
.attributes
.attributes()
.iter()
.filter_map(|a| {
if let Node::Attribute(a) = a {
if let NodeAttribute::Attribute(a) = a {
if a.key.to_string() == "style" {
if a.value.as_ref().and_then(value_to_string).is_some()
if a.value().and_then(value_to_string).is_some()
|| fancy_style_name(&a.key.to_string(), cx, a).is_some()
{
None
} else {
Some((a.key.span(), &a.value))
Some((a.key.span(), a.value()))
}
} else {
None
@@ -779,10 +786,10 @@ fn set_style_attribute_ssr(
.collect::<Vec<_>>();
let style_attrs = node
.attributes
.attributes()
.iter()
.filter_map(|node| {
if let Node::Attribute(node) = node {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
if name == "style" {
return if let Some((_, name, value)) =
@@ -825,7 +832,6 @@ fn set_style_attribute_ssr(
for (_span, value) in dyn_style_attr {
if let Some(value) = value {
template.push_str(" {};");
let value = value.as_ref();
holes.push(quote! {
&(#cx, #value).into_attribute(#cx).as_nameless_value_string()
.map(|a| leptos::leptos_dom::ssr::escape_attr(&a).to_string())
@@ -899,18 +905,18 @@ fn fragment_to_tokens(
let tokens = if lazy {
quote! {
{
leptos::Fragment::lazy(|| vec![
leptos::Fragment::lazy(|| [
#(#nodes),*
])
].to_vec())
#view_marker
}
}
} else {
quote! {
{
leptos::Fragment::new(vec![
leptos::Fragment::new([
#(#nodes),*
])
].to_vec())
#view_marker
}
}
@@ -948,18 +954,14 @@ fn node_to_tokens(
view_marker,
),
Node::Comment(_) | Node::Doctype(_) => Some(quote! {}),
Node::Text(node) => {
let value = node.value.as_ref();
Some(quote! {
leptos::leptos_dom::html::text(#value)
})
}
Node::Block(node) => {
let value = node.value.as_ref();
Some(quote! { #value })
}
Node::Attribute(node) => {
Some(attribute_to_tokens(cx, node, global_class))
Node::Text(node) => Some(quote! {
leptos::leptos_dom::html::text(#node)
}),
Node::Block(node) => Some(quote! { #node }),
Node::RawText(r) => {
let text = r.to_string_best();
let text = syn::LitStr::new(&text, r.span());
Some(quote! { #text })
}
Node::Element(node) => element_to_tokens(
cx,
@@ -980,6 +982,7 @@ fn element_to_tokens(
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> Option<TokenStream> {
let name = node.name();
if is_component_node(node) {
if let Some(slot) = get_slot(node) {
slot_to_tokens(cx, node, slot, parent_slots, global_class);
@@ -988,20 +991,17 @@ fn element_to_tokens(
Some(component_to_tokens(cx, node, global_class))
}
} else {
let tag = node.name.to_string();
let tag = name.to_string();
let name = if is_custom_element(&tag) {
let name = node.name.to_string();
let name = node.name().to_string();
quote! { leptos::leptos_dom::html::custom(#cx, leptos::leptos_dom::html::Custom::new(#name)) }
} else if is_svg_element(&tag) {
let name = &node.name;
parent_type = TagType::Svg;
quote! { leptos::leptos_dom::svg::#name(#cx) }
} else if is_math_ml_element(&tag) {
let name = &node.name;
parent_type = TagType::Math;
quote! { leptos::leptos_dom::math::#name(#cx) }
} else if is_ambiguous_element(&tag) {
let name = &node.name;
match parent_type {
TagType::Unknown => {
// We decided this warning was too aggressive, but I'll leave it here in case we want it later
@@ -1020,12 +1020,11 @@ fn element_to_tokens(
}
}
} else {
let name = &node.name;
parent_type = TagType::Html;
quote! { leptos::leptos_dom::html::#name(#cx) }
};
let attrs = node.attributes.iter().filter_map(|node| {
if let Node::Attribute(node) = node {
let attrs = node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
let name = name.trim();
if name.starts_with("class:")
@@ -1041,8 +1040,8 @@ fn element_to_tokens(
None
}
});
let class_attrs = node.attributes.iter().filter_map(|node| {
if let Node::Attribute(node) = node {
let class_attrs = node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
if let Some((fancy, _, _)) = fancy_class_name(&name, cx, node) {
Some(fancy)
@@ -1055,8 +1054,8 @@ fn element_to_tokens(
None
}
});
let style_attrs = node.attributes.iter().filter_map(|node| {
if let Node::Attribute(node) = node {
let style_attrs = node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
if let Some((fancy, _, _)) = fancy_style_name(&name, cx, node) {
Some(fancy)
@@ -1101,32 +1100,18 @@ fn element_to_tokens(
}),
false,
),
Node::Text(node) => {
if let Some(primitive) = value_to_string(&node.value) {
(quote! { #primitive }, true)
} else {
let value = node.value.as_ref();
(
quote! {
#[allow(unused_braces)] #value
},
false,
)
}
}
Node::Block(node) => {
if let Some(primitive) = value_to_string(&node.value) {
(quote! { #primitive }, true)
} else {
let value = node.value.as_ref();
(
quote! {
#[allow(unused_braces)] #value
},
false,
)
}
Node::Text(node) => (quote! { #node }, true),
Node::RawText(node) => {
let text = node.to_string_best();
let text = syn::LitStr::new(&text, node.span());
(quote! { #text }, true)
}
Node::Block(node) => (
quote! {
#node
},
false,
),
Node::Element(node) => (
element_to_tokens(
cx,
@@ -1139,9 +1124,7 @@ fn element_to_tokens(
.unwrap_or_default(),
false,
),
Node::Comment(_) | Node::Doctype(_) | Node::Attribute(_) => {
(quote! {}, false)
}
Node::Comment(_) | Node::Doctype(_) => (quote! {}, false),
};
if is_static {
quote! {
@@ -1172,7 +1155,7 @@ fn element_to_tokens(
fn attribute_to_tokens(
cx: &Ident,
node: &NodeAttribute,
node: &KeyedAttribute,
global_class: Option<&TokenTree>,
) -> TokenStream {
let span = node.key.span();
@@ -1303,7 +1286,7 @@ fn attribute_to_tokens(
// special case of global_class and class attribute
if name == "class"
&& global_class.is_some()
&& node.value.as_ref().and_then(value_to_string).is_none()
&& node.value().and_then(value_to_string).is_none()
{
let span = node.key.span();
proc_macro_error::emit_error!(span, "Combining a global class (view! { cx, class = ... }) \
@@ -1313,10 +1296,8 @@ fn attribute_to_tokens(
};
// all other attributes
let value = match node.value.as_ref() {
let value = match node.value() {
Some(value) => {
let value = value.as_ref();
quote! { #value }
}
None => quote_spanned! { span => "" },
@@ -1367,7 +1348,7 @@ pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool) {
pub(crate) fn slot_to_tokens(
cx: &Ident,
node: &NodeElement,
slot: &NodeAttribute,
slot: &KeyedAttribute,
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
global_class: Option<&TokenTree>,
) {
@@ -1376,19 +1357,19 @@ pub(crate) fn slot_to_tokens(
let name = convert_to_snake_case(if name.starts_with("slot:") {
name.replacen("slot:", "", 1)
} else {
node.name.to_string()
node.name().to_string()
});
let component_name = ident_from_tag_name(&node.name);
let span = node.name.span();
let component_name = ident_from_tag_name(node.name());
let span = node.name().span();
let Some(parent_slots) = parent_slots else {
proc_macro_error::emit_error!(span, "slots cannot be used inside HTML elements");
return;
};
let attrs = node.attributes.iter().filter_map(|node| {
if let Node::Attribute(node) = node {
let attrs = node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
if is_slot(node) {
None
} else {
@@ -1406,10 +1387,8 @@ pub(crate) fn slot_to_tokens(
let name = &attr.key;
let value = attr
.value
.as_ref()
.value()
.map(|v| {
let v = v.as_ref();
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
@@ -1474,9 +1453,9 @@ pub(crate) fn slot_to_tokens(
let slot = Ident::new(&slot, span);
if values.len() > 1 {
quote! {
.#slot(vec![
.#slot([
#(#values)*
])
].to_vec())
}
} else {
let value = &values[0];
@@ -1504,12 +1483,12 @@ pub(crate) fn component_to_tokens(
node: &NodeElement,
global_class: Option<&TokenTree>,
) -> TokenStream {
let name = &node.name;
let component_name = ident_from_tag_name(&node.name);
let span = node.name.span();
let name = node.name();
let component_name = ident_from_tag_name(node.name());
let span = node.name().span();
let attrs = node.attributes.iter().filter_map(|node| {
if let Node::Attribute(node) = node {
let attrs = node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
Some(node)
} else {
None
@@ -1526,10 +1505,8 @@ pub(crate) fn component_to_tokens(
let name = &attr.key;
let value = attr
.value
.as_ref()
.value()
.map(|v| {
let v = v.as_ref();
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
@@ -1637,7 +1614,7 @@ pub(crate) fn component_to_tokens(
}
pub(crate) fn event_from_attribute_node(
attr: &NodeAttribute,
attr: &KeyedAttribute,
force_undelegated: bool,
) -> (TokenStream, &Expr) {
let event_name = attr
@@ -1697,7 +1674,7 @@ fn ident_from_tag_name(tag_name: &NodeName) -> Ident {
fn expr_to_ident(expr: &syn::Expr) -> Option<&ExprPath> {
match expr {
syn::Expr::Block(block) => block.block.stmts.last().and_then(|stmt| {
if let syn::Stmt::Expr(expr) = stmt {
if let syn::Stmt::Expr(expr, ..) = stmt {
expr_to_ident(expr)
} else {
None
@@ -1708,15 +1685,15 @@ fn expr_to_ident(expr: &syn::Expr) -> Option<&ExprPath> {
}
}
fn is_slot(node: &NodeAttribute) -> bool {
fn is_slot(node: &KeyedAttribute) -> bool {
let key = node.key.to_string();
let key = key.trim();
key == "slot" || key.starts_with("slot:")
}
fn get_slot(node: &NodeElement) -> Option<&NodeAttribute> {
node.attributes.iter().find_map(|node| {
if let Node::Attribute(node) = node {
fn get_slot(node: &NodeElement) -> Option<&KeyedAttribute> {
node.attributes().iter().find_map(|node| {
if let NodeAttribute::Attribute(node) = node {
if is_slot(node) {
Some(node)
} else {
@@ -1744,7 +1721,7 @@ fn is_self_closing(node: &NodeElement) -> bool {
// self-closing tags
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
matches!(
node.name.to_string().as_str(),
node.name().to_string().as_str(),
"area"
| "base"
| "br"
@@ -1899,13 +1876,13 @@ fn parse_event(event_name: &str) -> (&str, bool) {
fn fancy_class_name<'a>(
name: &str,
cx: &Ident,
node: &'a NodeAttribute,
node: &'a KeyedAttribute,
) -> Option<(TokenStream, String, &'a Expr)> {
// special case for complex class names:
// e.g., Tailwind `class=("mt-[calc(100vh_-_3rem)]", true)`
if name == "class" {
if let Some(expr) = node.value.as_ref() {
if let syn::Expr::Tuple(tuple) = expr.as_ref() {
if let Some(expr) = node.value() {
if let syn::Expr::Tuple(tuple) = expr {
if tuple.elems.len() == 2 {
let span = node.key.span();
let class = quote_spanned! {
@@ -1948,12 +1925,12 @@ fn fancy_class_name<'a>(
fn fancy_style_name<'a>(
name: &str,
cx: &Ident,
node: &'a NodeAttribute,
node: &'a KeyedAttribute,
) -> Option<(TokenStream, String, &'a Expr)> {
// special case for complex dynamic style names:
if name == "style" {
if let Some(expr) = node.value.as_ref() {
if let syn::Expr::Tuple(tuple) = expr.as_ref() {
if let Some(expr) = node.value() {
if let syn::Expr::Tuple(tuple) = expr {
if tuple.elems.len() == 2 {
let span = node.key.span();
let style = quote_spanned! {

View File

@@ -44,7 +44,7 @@ error: unexpected end of input, expected assignment `=`
47 | #[prop(default)] default: bool,
| ^
error: unexpected end of input, expected one of: `::`, `<`, `_`, literal, `const`, `ref`, `mut`, `&`, parentheses, square brackets, `..`, `const`
error: unexpected end of input, expected one of: identifier, `::`, `<`, `_`, literal, `const`, `ref`, `mut`, `&`, parentheses, square brackets, `..`, `const`
= help: try `#[prop(default=5 * 10)]`
--> tests/ui/component.rs:56:22

View File

@@ -44,7 +44,7 @@ error: unexpected end of input, expected assignment `=`
45 | #[prop(default)] default: bool,
| ^
error: unexpected end of input, expected one of: `::`, `<`, `_`, literal, `const`, `ref`, `mut`, `&`, parentheses, square brackets, `..`, `const`
error: unexpected end of input, expected one of: identifier, `::`, `<`, `_`, literal, `const`, `ref`, `mut`, `&`, parentheses, square brackets, `..`, `const`
= help: try `#[prop(default=5 * 10)]`
--> tests/ui/component_absolute.rs:54:22

View File

@@ -110,7 +110,7 @@ pub use slice::*;
pub use spawn::*;
pub use spawn_microtask::*;
pub use stored_value::*;
pub use suspense::SuspenseContext;
pub use suspense::{GlobalSuspenseContext, SuspenseContext};
pub use trigger::*;
mod macros {

View File

@@ -5,8 +5,9 @@ use crate::{
runtime::{with_runtime, RuntimeId},
serialization::Serializable,
spawn::spawn_local,
use_context, Memo, ReadSignal, Scope, ScopeProperty, SignalGetUntracked,
SignalSet, SignalUpdate, SignalWith, SuspenseContext, WriteSignal,
use_context, GlobalSuspenseContext, Memo, ReadSignal, Scope, ScopeProperty,
SignalGetUntracked, SignalSet, SignalUpdate, SignalWith, SuspenseContext,
WriteSignal,
};
use std::{
any::Any,
@@ -820,6 +821,7 @@ where
f: impl FnOnce(&T) -> U,
location: &'static Location<'static>,
) -> Option<U> {
let global_suspense_cx = use_context::<GlobalSuspenseContext>(cx);
let suspense_cx = use_context::<SuspenseContext>(cx);
let v = self
@@ -882,6 +884,22 @@ where
}
}
}
if let Some(g) = &global_suspense_cx {
if let Ok(ref mut contexts) = suspense_contexts.try_borrow_mut()
{
let s = g.as_inner();
if !contexts.contains(s) {
contexts.insert(*s);
if !has_value {
s.increment(
serializable != ResourceSerialization::Local,
);
}
}
}
}
};
create_isomorphic_effect(cx, increment);
@@ -1005,6 +1023,7 @@ where
}
}
#[derive(Clone)]
pub(crate) enum AnyResource {
Unserializable(Rc<dyn UnserializableResource>),
Serializable(Rc<dyn SerializableResource>),

View File

@@ -743,7 +743,7 @@ impl Runtime {
S: 'static,
T: 'static,
{
let resources = self.resources.borrow();
let resources = { self.resources.borrow().clone() };
let res = resources.get(id);
if let Some(res) = res {
let res_state = match res {
@@ -796,7 +796,8 @@ impl Runtime {
cx: Scope,
) -> FuturesUnordered<PinnedFuture<(ResourceId, String)>> {
let f = FuturesUnordered::new();
for (id, resource) in self.resources.borrow().iter() {
let resources = { self.resources.borrow().clone() };
for (id, resource) in resources.iter() {
if let AnyResource::Serializable(resource) = resource {
f.push(resource.to_serialization_resolver(cx, id));
}

View File

@@ -39,8 +39,7 @@ impl<T> Clone for StoredValue<T> {
impl<T> Copy for StoredValue<T> {}
impl<T> StoredValue<T> {
/// Returns a clone of the signals current value, subscribing the effect
/// to this signal.
/// Returns a clone of the current stored value.
///
/// # Panics
/// Panics if you try to access a value stored in a [`Scope`] that has been disposed.
@@ -70,7 +69,7 @@ impl<T> StoredValue<T> {
self.try_get_value().expect("could not get stored value")
}
/// Same as [`StoredValue::get`] but will not panic by default.
/// Same as [`StoredValue::get_value`] but will not panic by default.
#[track_caller]
pub fn try_get_value(&self) -> Option<T>
where
@@ -79,7 +78,7 @@ impl<T> StoredValue<T> {
self.try_with_value(T::clone)
}
/// Applies a function to the current stored value.
/// Applies a function to the current stored value and returns the result.
///
/// # Panics
/// Panics if you try to access a value stored in a [`Scope`] that has been disposed.
@@ -105,8 +104,8 @@ impl<T> StoredValue<T> {
self.try_with_value(f).expect("could not get stored value")
}
/// Same as [`StoredValue::with`] but returns [`Some(O)]` only if
/// the signal is still valid. [`None`] otherwise.
/// Same as [`StoredValue::with_value`] but returns [`Some(O)]` only if
/// the stored value has not yet been disposed. [`None`] otherwise.
pub fn try_with_value<O>(&self, f: impl FnOnce(&T) -> O) -> Option<O> {
with_runtime(self.runtime, |runtime| {
let value = {
@@ -161,8 +160,8 @@ impl<T> StoredValue<T> {
.expect("could not set stored value");
}
/// Same as [`Self::update`], but returns [`Some(O)`] if the
/// signal is still valid, [`None`] otherwise.
/// Same as [`Self::update_value`], but returns [`Some(O)`] if the
/// stored value has not yet been disposed, [`None`] otherwise.
pub fn try_update_value<O>(self, f: impl FnOnce(&mut T) -> O) -> Option<O> {
with_runtime(self.runtime, |runtime| {
let values = runtime.stored_values.borrow();
@@ -195,8 +194,8 @@ impl<T> StoredValue<T> {
self.try_set_value(value);
}
/// Same as [`Self::set`], but returns [`None`] if the signal is
/// still valid, [`Some(T)`] otherwise.
/// Same as [`Self::set_value`], but returns [`None`] if the
/// stored value has not yet been disposed, [`Some(T)`] otherwise.
pub fn try_set_value(&self, value: T) -> Option<T> {
with_runtime(self.runtime, |runtime| {
let values = runtime.stored_values.borrow();

View File

@@ -2,11 +2,12 @@
#![forbid(unsafe_code)]
use crate::{
create_rw_signal, create_signal, queue_microtask, store_value, ReadSignal,
RwSignal, Scope, SignalUpdate, StoredValue, WriteSignal,
create_isomorphic_effect, create_rw_signal, create_signal, queue_microtask,
signal::SignalGet, store_value, ReadSignal, RwSignal, Scope, SignalSet,
SignalUpdate, StoredValue, WriteSignal,
};
use futures::Future;
use std::{borrow::Cow, collections::VecDeque, pin::Pin};
use std::{borrow::Cow, cell::RefCell, collections::VecDeque, pin::Pin};
/// Tracks [`Resource`](crate::Resource)s that are read under a suspense context,
/// i.e., within a [`Suspense`](https://docs.rs/leptos_core/latest/leptos_core/fn.Suspense.html) component.
@@ -20,6 +21,24 @@ pub struct SuspenseContext {
pub(crate) should_block: StoredValue<bool>,
}
/// A single, global suspense context that will be checked when resources
/// are read. This wont be “blocked” by lower suspense components. This is
/// useful for e.g., holding route transitions.
#[derive(Copy, Clone, Debug)]
pub struct GlobalSuspenseContext(SuspenseContext);
impl GlobalSuspenseContext {
/// Creates an empty global suspense context.
pub fn new(cx: Scope) -> Self {
Self(SuspenseContext::new(cx))
}
/// Returns a reference to the underlying suspense context.
pub fn as_inner(&self) -> &SuspenseContext {
&self.0
}
}
impl SuspenseContext {
/// Whether the suspense contains local resources at this moment,
/// and therefore can't be serialized
@@ -32,6 +51,25 @@ impl SuspenseContext {
pub fn should_block(&self) -> bool {
self.should_block.get_value()
}
/// Returns a `Future` that resolves when this suspense is resolved.
pub fn to_future(&self, cx: Scope) -> impl Future<Output = ()> {
use futures::StreamExt;
let pending_resources = self.pending_resources;
let (tx, mut rx) = futures::channel::mpsc::channel(1);
let tx = RefCell::new(tx);
queue_microtask(move || {
create_isomorphic_effect(cx, move |_| {
if pending_resources.get() == 0 {
_ = tx.borrow_mut().try_send(());
}
})
});
async move {
rx.next().await;
}
}
}
impl std::hash::Hash for SuspenseContext {
@@ -98,6 +136,12 @@ impl SuspenseContext {
});
}
/// Resets the counter of pending resources.
pub fn clear(&self) {
self.set_pending_resources.set(0);
self.pending_serializable_resources.set(0);
}
/// Tests whether all of the pending resources have resolved.
pub fn ready(&self) -> bool {
self.pending_resources

View File

@@ -11,7 +11,7 @@ use crate::{
/// Reactive Trigger, notifies reactive code to rerun.
///
/// See [`create_trigger`] for more.
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Trigger {
pub(crate) runtime: RuntimeId,
pub(crate) id: NodeId,

View File

@@ -95,8 +95,63 @@ where
let action = use_resolved_path(cx, move || action.clone())
.get()
.unwrap_or_default();
// multipart POST (setting Context-Type breaks the request)
if method == "post" && enctype == "multipart/form-data" {
ev.prevent_default();
ev.stop_propagation();
let on_response = on_response.clone();
spawn_local(async move {
let res = gloo_net::http::Request::post(&action)
.header("Accept", "application/json")
.redirect(RequestRedirect::Follow)
.body(form_data)
.send()
.await;
match res {
Err(e) => {
error!("<Form/> error while POSTing: {e:#?}");
if let Some(error) = error {
error.set(Some(Box::new(e)));
}
}
Ok(resp) => {
if let Some(version) = action_version {
version.update(|n| *n += 1);
}
if let Some(error) = error {
error.set(None);
}
if let Some(on_response) = on_response.clone() {
on_response(resp.as_raw());
}
// Check all the logical 3xx responses that might
// get returned from a server function
if resp.redirected() {
let resp_url = &resp.url();
match Url::try_from(resp_url.as_str()) {
Ok(url) => {
request_animation_frame(move || {
if let Err(e) = navigate(
&format!(
"{}{}",
url.pathname, url.search,
),
Default::default(),
) {
warn!("{}", e);
}
});
}
Err(e) => warn!("{}", e),
}
}
}
}
});
}
// POST
if method == "post" {
else if method == "post" {
ev.prevent_default();
ev.stop_propagation();

View File

@@ -90,10 +90,14 @@ where
children: Children,
) -> HtmlElement<leptos::html::A> {
#[cfg(not(any(feature = "hydrate", feature = "csr")))]
_ = state;
{
_ = state;
}
#[cfg(not(any(feature = "hydrate", feature = "csr")))]
_ = replace;
{
_ = replace;
}
let location = use_location(cx);
let is_active = create_memo(cx, move |_| match href.get() {

View File

@@ -1,6 +1,7 @@
mod form;
mod link;
mod outlet;
mod progress;
mod redirect;
mod route;
mod router;
@@ -9,6 +10,7 @@ mod routes;
pub use form::*;
pub use link::*;
pub use outlet::*;
pub use progress::*;
pub use redirect::*;
pub use route::*;
pub use router::*;

View File

@@ -1,6 +1,6 @@
use crate::{
animation::{Animation, AnimationState},
use_is_back_navigation, use_route,
use_is_back_navigation, use_route, SetIsRouting,
};
use leptos::{leptos_dom::HydrationCtx, *};
use std::{cell::Cell, rc::Rc};
@@ -45,6 +45,35 @@ pub fn Outlet(cx: Scope) -> impl IntoView {
}
});
let outlet: Signal<Option<View>> =
if cfg!(any(feature = "csr", feature = "hydrate"))
&& use_context::<SetIsRouting>(cx).is_some()
{
let global_suspense = expect_context::<GlobalSuspenseContext>(cx);
let is_fallback =
create_memo(cx, move |_| !global_suspense.as_inner().ready());
let last_two_views = create_memo(
cx,
move |prev: Option<&(Option<View>, Option<View>)>| match prev {
None => (outlet.get(), None),
Some((curr, _)) => (outlet.get(), curr.clone()),
},
);
create_memo(cx, move |_| {
let (curr, prev) = last_two_views.get();
if is_fallback.get() && prev.is_some() {
prev
} else {
curr
}
})
.into()
} else {
outlet.into()
};
leptos::leptos_dom::DynChild::new_with_id(id, move || outlet.get())
}

View File

@@ -0,0 +1,66 @@
use leptos::{leptos_dom::helpers::IntervalHandle, *};
/// A visible indicator that the router is in the process of navigating
/// to another route.
///
/// This is used when `<Router set_is_routing>` has been provided, to
/// provide some visual indicator that the page is currently loading
/// async data, so that it is does not appear to have frozen. It can be
/// styled independently.
#[component]
pub fn RoutingProgress(
cx: Scope,
/// Whether the router is currently loading the new page.
#[prop(into)]
is_routing: Signal<bool>,
/// The maximum expected time for loading, which is used to
/// calibrate the animation process.
#[prop(optional, into)]
max_time: std::time::Duration,
/// The time to show the full progress bar after page has loaded, before hiding it. (Defaults to 100ms.)
#[prop(default = std::time::Duration::from_millis(250))]
before_hiding: std::time::Duration,
/// CSS classes to be applied to the `<progress>`.
#[prop(optional, into)]
class: String,
) -> impl IntoView {
const INCREMENT_EVERY_MS: f32 = 5.0;
let expected_increments =
max_time.as_secs_f32() / (INCREMENT_EVERY_MS / 1000.0);
let percent_per_increment = 100.0 / expected_increments;
let (is_showing, set_is_showing) = create_signal(cx, false);
let (progress, set_progress) = create_signal(cx, 0.0);
create_effect(cx, move |prev: Option<Option<IntervalHandle>>| {
if is_routing.get() {
set_is_showing.set(true);
set_interval_with_handle(
move || {
set_progress.update(|n| *n += percent_per_increment);
},
std::time::Duration::from_millis(INCREMENT_EVERY_MS as u64),
)
.ok()
} else {
set_progress.set(100.0);
set_timeout(
move || {
set_progress.set(0.0);
set_is_showing.set(false);
},
before_hiding,
);
if let Some(Some(interval)) = prev {
interval.clear();
}
None
}
});
view! { cx,
<Show when=move || is_showing.get() fallback=|_| ()>
<progress class=class.clone() min="0" max="100" value=move || progress.get()/>
</Show>
}
}

View File

@@ -24,6 +24,9 @@ pub fn Router(
/// A fallback that should be shown if no route is matched.
#[prop(optional)]
fallback: Option<fn(Scope) -> View>,
/// A signal that will be set while the navigation process is underway.
#[prop(optional, into)]
set_is_routing: Option<SignalSetter<bool>>,
/// The `<Router/>` should usually wrap your whole page. It can contain
/// any elements, and should include a [Routes](crate::Routes) component somewhere
/// to define and display [Route](crate::Route)s.
@@ -32,10 +35,17 @@ pub fn Router(
// create a new RouterContext and provide it to every component beneath the router
let router = RouterContext::new(cx, base, fallback);
provide_context(cx, router);
provide_context(cx, GlobalSuspenseContext::new(cx));
if let Some(set_is_routing) = set_is_routing {
provide_context(cx, SetIsRouting(set_is_routing));
}
children(cx)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) struct SetIsRouting(pub SignalSetter<bool>);
/// Context type that contains information about the current router state.
#[derive(Debug, Clone)]
pub struct RouterContext {
@@ -228,6 +238,11 @@ impl RouterContextInner {
resolve_path("", to, None).map(String::from)
};
// reset count of pending resources at global level
expect_context::<GlobalSuspenseContext>(cx)
.as_inner()
.clear();
match resolved_to {
None => Err(NavigationError::NotRoutable(to.to_string())),
Some(resolved_to) => {
@@ -262,18 +277,32 @@ impl RouterContextInner {
move |state| *state = next_state
});
self.path_stack.update_value(|stack| {
let global_suspense =
expect_context::<GlobalSuspenseContext>(cx);
let path_stack = self.path_stack;
path_stack.update_value(|stack| {
stack.push(resolved_to.clone())
});
if referrers.borrow().len() == len {
this.navigate_end(LocationChange {
value: resolved_to,
replace: false,
scroll: true,
state,
})
let set_is_routing = use_context::<SetIsRouting>(cx);
if let Some(set_is_routing) = set_is_routing {
set_is_routing.0.set(true);
}
spawn_local(async move {
if let Some(set_is_routing) = set_is_routing {
global_suspense.as_inner().to_future(cx).await;
set_is_routing.0.set(false);
}
if referrers.borrow().len() == len {
this.navigate_end(LocationChange {
value: resolved_to,
replace: false,
scroll: true,
state,
});
}
});
}
Ok(())

View File

@@ -4,7 +4,7 @@ use crate::{
expand_optionals, get_route_matches, join_paths, Branch, Matcher,
RouteDefinition, RouteMatch,
},
use_is_back_navigation, RouteContext, RouterContext,
use_is_back_navigation, RouteContext, RouterContext, SetIsRouting,
};
use leptos::{leptos_dom::HydrationCtx, *};
use std::{
@@ -56,6 +56,7 @@ pub fn Routes(
let id = HydrationCtx::id();
let root = root_route(cx, base_route, route_states, root_equal);
leptos::leptos_dom::DynChild::new_with_id(id, move || root.get())
.into_view(cx)
}
@@ -408,37 +409,67 @@ fn root_route(
) -> Memo<Option<View>> {
let root_cx = RefCell::new(None);
create_memo(cx, move |prev| {
provide_context(cx, route_states);
route_states.with(|state| {
if state.routes.borrow().is_empty() {
Some(base_route.outlet(cx).into_view(cx))
} else {
let root = state.routes.borrow();
let root = root.get(0);
if let Some(route) = root {
provide_context(cx, route.clone());
}
if prev.is_none() || !root_equal.get() {
let (root_view, _) = cx.run_child_scope(|cx| {
let prev_cx = std::mem::replace(
&mut *root_cx.borrow_mut(),
Some(cx),
);
if let Some(prev_cx) = prev_cx {
prev_cx.dispose();
}
root.as_ref()
.map(|route| route.outlet(cx).into_view(cx))
});
root_view
let root_view = create_memo(cx, {
let root_equal = Rc::clone(&root_equal);
move |prev| {
provide_context(cx, route_states);
route_states.with(|state| {
if state.routes.borrow().is_empty() {
Some(base_route.outlet(cx).into_view(cx))
} else {
prev.cloned().unwrap()
let root = state.routes.borrow();
let root = root.get(0);
if let Some(route) = root {
provide_context(cx, route.clone());
}
if prev.is_none() || !root_equal.get() {
let (root_view, _) = cx.run_child_scope(|cx| {
let prev_cx = std::mem::replace(
&mut *root_cx.borrow_mut(),
Some(cx),
);
if let Some(prev_cx) = prev_cx {
prev_cx.dispose();
}
root.as_ref()
.map(|route| route.outlet(cx).into_view(cx))
});
root_view
} else {
prev.cloned().unwrap()
}
}
})
}
});
if cfg!(any(feature = "csr", feature = "hydrate"))
&& use_context::<SetIsRouting>(cx).is_some()
{
let global_suspense = expect_context::<GlobalSuspenseContext>(cx);
let is_fallback =
create_memo(cx, move |_| !global_suspense.as_inner().ready());
let last_two_views = create_memo(
cx,
move |prev: Option<&(Option<View>, Option<View>)>| match prev {
None => (root_view.get(), None),
Some((curr, _)) => (root_view.get(), curr.clone()),
},
);
create_memo(cx, move |_| {
let (curr, prev) = last_two_views.get();
if is_fallback.get() && prev.is_some() && !root_equal.get() {
prev
} else {
curr
}
})
})
} else {
root_view
}
}
#[derive(Clone, Debug, PartialEq)]

View File

@@ -42,7 +42,11 @@ impl TryFrom<&str> for Url {
Ok(Self {
origin: url.origin(),
pathname: url.pathname(),
search: url.search(),
search: url
.search()
.strip_prefix('?')
.map(String::from)
.unwrap_or_default(),
search_params: ParamsMap(
try_iter(&url.search_params())
.map_js_error()?

View File

@@ -15,7 +15,7 @@ serde_qs = "0.12"
thiserror = "1"
serde_json = "1"
quote = "1"
syn = { version = "1", features = ["full", "parsing", "extra-traits"] }
syn = { version = "2", features = ["full", "parsing", "extra-traits"] }
proc-macro2 = "1"
ciborium = "0.2"
xxhash-rust = { version = "0.8", features = ["const_xxh64"] }

View File

@@ -11,7 +11,7 @@ description = "The default implementation of the server_fn macro without a conte
proc-macro = true
[dependencies]
syn = { version = "1", features = ["full"] }
syn = { version = "2", features = ["full"] }
server_fn_macro = { workspace = true }
[dev-dependencies]

View File

@@ -11,7 +11,7 @@ readme = "../README.md"
[dependencies]
serde = { version = "1", features = ["derive"] }
quote = "1"
syn = { version = "1", features = ["full", "parsing", "extra-traits"] }
syn = { version = "2", features = ["full", "parsing", "extra-traits"] }
proc-macro2 = "1"
proc-macro-error = "1"
xxhash-rust = { version = "0.8.6", features = ["const_xxh64"] }

View File

@@ -75,9 +75,11 @@ pub fn server_macro_impl(
struct_name,
prefix,
encoding,
fn_path,
..
} = syn::parse2::<ServerFnName>(args)?;
let prefix = prefix.unwrap_or_else(|| Literal::string(""));
let fn_path = fn_path.unwrap_or_else(|| Literal::string(""));
let encoding = quote!(#server_fn_path::#encoding);
let body = syn::parse::<ServerFnBody>(body.into())?;
@@ -213,7 +215,11 @@ pub fn server_macro_impl(
}
fn url() -> &'static str {
if !#fn_path.is_empty(){
#fn_path
} else {
#server_fn_path::const_format::concatcp!(#fn_name_as_str, #server_fn_path::xxhash_rust::const_xxh64::xxh64(concat!(env!(#key_env_var), ":", file!(), ":", line!(), ":", column!()).as_bytes(), 0))
}
}
fn encoding() -> #server_fn_path::Encoding {
@@ -260,6 +266,8 @@ struct ServerFnName {
prefix: Option<Literal>,
_comma2: Option<Token![,]>,
encoding: Path,
_comma3: Option<Token![,]>,
fn_path: Option<Literal>,
}
impl Parse for ServerFnName {
@@ -280,6 +288,8 @@ impl Parse for ServerFnName {
}
})
.unwrap_or_else(|_| syn::parse_quote!(Encoding::Url));
let _comma3 = input.parse()?;
let fn_path = input.parse()?;
Ok(Self {
struct_name,
@@ -287,6 +297,8 @@ impl Parse for ServerFnName {
prefix,
_comma2,
encoding,
_comma3,
fn_path,
})
}
}