Compare commits

..

17 Commits

Author SHA1 Message Date
Greg Johnston
722ea734d9 docs: add use_navigate to router docs in guide 2023-08-09 20:43:23 -04:00
Greg Johnston
0b650ee2dc Merge pull request #1523 from leptos-rs/more-docs
Additional random docs
2023-08-09 20:24:48 -04:00
Greg Johnston
4def35cb45 docs: add <Await/> 2023-08-09 20:24:04 -04:00
Greg Johnston
0e56f27e0d docs: add watch 2023-08-09 20:19:12 -04:00
Greg Johnston
bd8983f462 docs: expand docs on Axum State/FromRef pattern 2023-08-09 20:14:37 -04:00
Greg Johnston
7ef635d9cf docs: deployment 2023-08-09 20:09:54 -04:00
Joseph Cruz
19ea6fae6a test(todo_app_sqlite_axum): add e2e tests (#1514) (#1515)
* refactor(examples): pull up cargo leptos tasks

* test(todo_app_sqlite_axum): add e2e tests
2023-08-09 08:37:28 -04:00
Joseph Cruz
651a111db9 fix(suspense-tests): build errors (#1517) (#1518) 2023-08-09 08:36:25 -04:00
Danik Vitek
3a98bdb3c2 fix: use current pathname for create_query_signal (#1508) 2023-08-07 20:25:22 -04:00
Greg Johnston
f01b982cff fix: render empty dynamic text node in HTML as (closes #1382) (#1507) 2023-08-07 18:04:56 -04:00
Joseph Cruz
69dd96f76f test(todo_app_sqlite): add e2e tests (#1448) (#1467) 2023-08-07 17:51:24 -04:00
starmaker
329ae08e60 chore: enable stable support for rkyv feature (#1503) 2023-08-07 08:54:02 -04:00
Greg Johnston
1e13ad8fee perf: in hydration, reuse existing text node rather than destroying and remounting (#1506) 2023-08-07 08:34:10 -04:00
Geert Stappers
e0c9a9523a docs: typo
Signed-off-by: Geert Stappers <stappers@stappers.nl>
2023-08-04 10:56:51 -04:00
Mark Catley
0726a3034d examples: fix github links (#1493) 2023-08-04 07:55:04 -04:00
Greg Johnston
a88d047eff template refactor + snapshot tests (#1435) 2023-08-04 07:54:03 -04:00
mateusvmv
4001561987 fix: scoping of JS variable names in inline scripts (#1489) 2023-08-03 08:46:06 -04:00
84 changed files with 10621 additions and 2345 deletions

View File

@@ -84,6 +84,11 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Chrome Webriver
run: |
sudo apt-get update
sudo apt-get install chromium-chromedriver
# Run Cargo Make Task
- name: ${{ inputs.cargo_make_task }}
run: |

View File

@@ -26,7 +26,7 @@ cargo init leptos-tutorial
cargo add leptos --features=csr,nightly
```
Or you can leave off `nighly` if you're using stable Rust
Or you can leave off `nightly` if you're using stable Rust
```bash
cargo add leptos --features=csr
```

View File

@@ -43,5 +43,5 @@
- [Responses and Redirects](./server/27_response.md)
- [Progressive Enhancement and Graceful Degradation](./progressive_enhancement/README.md)
- [`<ActionForm/>`s](./progressive_enhancement/action_form.md)
- [Deployment]()
- [Deployment](./deployment.md)
- [Appendix: Optimizing WASM Binary Size](./appendix_binary_size.md)

View File

@@ -69,6 +69,34 @@ Every time one of the resources is reloading, the `"Loading..."` fallback will s
This inversion of the flow of control makes it easier to add or remove individual resources, as you dont need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which well talk about during a later chapter.
## `<Await/>`
In youre simply trying to wait for some `Future` to resolve before rendering, you may find the `<Await/>` component helpful in reducing boilerplate. `<Await/>` essentially combines a resource with the source argument `|| ()` with a `<Suspense/>` with no fallback.
In other words:
1. It only polls the `Future` once, and does not respond to any reactive changes.
2. It does not render anything until the `Future` resolves.
3. After the `Future` resolves, its binds its data to whatever variable name you choose and then renders its children with that variable in scope.
```rust
async fn fetch_monkeys(monkey: i32) -> i32 {
// maybe this didn't need to be async
monkey * 2
}
view! { cx,
<Await
// `future` provides the `Future` to be resolved
future=|cx| fetch_monkeys(3)
// the data is bound to whatever variable name you provide
bind:data
>
// you receive the data by reference and can use it in your view here
<p>{*data} " little monkeys, jumping on the bed."</p>
</Await>
}
```
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs)
<iframe src="https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs" width="100%" height="1000px" style="max-height: 100vh"></iframe>

View File

@@ -0,0 +1,74 @@
# Deployment
There are as many ways to deploy a web application as there are developers, let alone applications. But there are a couple useful tips to keep in mind when deploying an app.
## General Advice
1. Remember: Always deploy Rust apps built in `--release` mode, not debug mode. This has a huge effect on both performance and binary size.
2. Test locally in release mode as well. The framework applies certain optimizations in release mode that it does not apply in debug mode, so its possible for bugs to surface at this point. (If your app behaves differently or you do encounter a bug, its likely a framework-level bug and you should open a GitHub issue with a reproduction.)
> We asked users to submit their deployment setups to help with this chapter. Ill quote from them below, but you can read the full thread [here](https://github.com/leptos-rs/leptos/issues/1152).
## Deploying a Client-Side-Rendered App
If youve been building an app that only uses client-side rendering, working with Trunk as a dev server and build tool, the process is quite easy.
```bash
trunk build --release
```
`trunk build` will create a number of build artifacts in a `dist/` directory. Publishing `dist` somewhere online should be all you need to deploy your app. This should work very similarly to deploying any JavaScript application.
> Read more: [Deploying to Vercel with GitHub Actions](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1577861900).
## Deploying a Full-Stack App
The most popular way for people to deploy full-stack apps built with `cargo-leptos` is to use a cloud hosting service that supports deployment via a Docker build. Heres a sample `Dockerfile`, which is based on the one we use to deploy the Leptos website.
```dockerfile
# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-bullseye as builder
# If youre using stable, use this instead
# FROM rust:1.70-bullseye as builder
# Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin
# Install cargo-leptos
RUN cargo binstall cargo-leptos -y
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown
# Make an /app dir, which everything will eventually live in
RUN mkdir -p /app
WORKDIR /app
COPY . .
# Build the app
RUN cargo leptos build --release -vv
FROM rustlang/rust:nightly-bullseye as runner
# Copy the server binary to the /app directory
COPY --from=builder /app/target/server/release/leptos_website /app/
# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/site
# Copy Cargo.toml if its needed at runtime
COPY --from=builder /app/Cargo.toml /app/
WORKDIR /app
# Set any required env variables and
ENV RUST_LOG="info"
ENV APP_ENVIRONMENT="production"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT="site"
EXPOSE 8080
# Run the server
CMD ["/app/leptos_website"]
```
> Read more: [`gnu` and `musl` build files for Leptos apps](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1634916088).

View File

@@ -109,6 +109,34 @@ create_effect(cx, move |prev_value| {
Every time `count` is updated, this effect wil rerun. This is what allows reactive, fine-grained updates to the DOM.
## Explicit, Cancelable Tracking with `watch`
In addition to `create_effect`, Leptos provides a [`watch`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.watch.html) function, which can be used for two main purposes:
1. Separating tracking and responding to changes by explicitly passing in a set of values to track.
2. Canceling tracking by calling a stop function.
Like `create_resource`, `watch` takes a first argument, which is reactively tracked, and a second, which is not. Whenever a reactive value in its `deps` argument is changed, the `callback` is run. `watch` returns a function that can be called to stop tracking the dependencies.
```rust
let (num, set_num) = create_signal(cx, 0);
let stop = watch(
cx,
move || num.get(),
move |num, prev_num, _| {
log::debug!("Number: {}; Prev: {:?}", num, prev_num);
},
false,
);
set_num.set(1); // > "Number: 1; Prev: Some(0)"
stop(); // stop watching
set_num.set(2); // (nothing happens)
```
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D)
<iframe src="https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D" width="100%" height="1000px" style="max-height: 100vh"></iframe>

View File

@@ -18,6 +18,20 @@ The router also provides an [`<A>`](https://docs.rs/leptos_router/latest/leptos_
1. Correctly resolves relative nested routes. Relative routing with ordinary `<a>` tags can be tricky. For example, if you have a route like `/post/:id`, `<A href="1">` will generate the correct relative route, but `<a href="1">` likely will not (depending on where it appears in your view.) `<A/>` resolves routes relative to the path of the nested route within which it appears.
2. Sets the `aria-current` attribute to `page` if this link is the active link (i.e., its a link to the page youre on). This is helpful for accessibility and for styling. For example, if you want to set the link a different color if its a link to the page youre currently on, you can match this attribute with a CSS selector.
## Navigating Programmatically
Your most-used methods of navigating between pages should be with `<a>` and `<form>` elements or with the enhanced `<A/>` and `<Form/>` components. Using links and forms to navigate is the best solution for accessibility and graceful degradation.
On occasion, though, youll want to navigate programmatically, i.e., call a function that can navigate to a new page. In that case, you should use the [`use_navigate`](https://docs.rs/leptos_router/latest/leptos_router/fn.use_navigate.html) function.
```rust
let navigate = leptos_router::use_navigate(cx);
navigate("/somewhere", Default::default());
```
> You should almost never do something like `<button on:click=move |_| navigate(/* ... */)>`. Any `on:click` that navigates should be an `<a>`, for reasons of accessibility.
The second argument here is a set of [`NavigateOptions`](https://docs.rs/leptos_router/latest/leptos_router/struct.NavigateOptions.html), which includes options to resolve the navigation relative to the current route as the `<A/>` component does, replace it in the navigation stack, include some navigation state, and maintain the current scroll state on navigation.
> Once again, this is the same example. Check out the relative `<A/>` components, and take a look at the CSS in `index.html` to see the ARIA-based styling.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-fy4tjv?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D)

View File

@@ -62,7 +62,21 @@ pub async fn axum_extract(cx: Scope) -> Result<String, ServerFnError> {
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same `extract()` pattern.
> Note: For now, the Axum `extract` function only supports extractors for which the state is `()`, i.e., you can't yet use it to extract `State(_)`. You can access `State(_)` by using a custom handler that extracts the state and then provides it via context. [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
The Axum `extract` function only supports extractors for which the state is `()`. If you need an extractor that uses `State`, you should use [`extract_with_state`](https://docs.rs/leptos_axum/latest/leptos_axum/fn.extract_with_state.html). This requires you to provide the state. You can do this by extending the existing `LeptosOptions` state using the Axum `FromRef` pattern, which providing the state as context during render and server functions with custom handlers.
```rust
use axum::extract::FromRef;
/// Derive FromRef to allow multiple items in state, using Axums
/// SubStates pattern.
#[derive(FromRef, Debug, Clone)]
pub struct AppState{
pub leptos_options: LeptosOptions,
pub pool: SqlitePool
}
```
[Click here for an example of providing context in custom handlers](https://github.com/leptos-rs/leptos/blob/19ea6fae6aec2a493d79cc86612622d219e6eebb/examples/session_auth_axum/src/main.rs#L24-L44).
## A Note about Data-Loading Patterns

View File

@@ -1,5 +1,7 @@
extend = { path = "./cargo-leptos.toml" }
[tasks.integration-test]
dependencies = ["cargo-leptos-e2e"]
dependencies = ["install-cargo-leptos", "cargo-leptos-e2e"]
[tasks.cargo-leptos-e2e]
command = "cargo"

View File

@@ -0,0 +1,55 @@
[tasks.install-cargo-leptos]
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
[tasks.build]
clear = true
command = "cargo"
args = ["leptos", "build"]
[tasks.check]
clear = true
dependencies = ["check-debug", "check-release"]
[tasks.check-debug]
toolchain = "nightly"
command = "cargo"
args = ["check-all-features"]
install_crate = "cargo-all-features"
[tasks.check-release]
toolchain = "nightly"
command = "cargo"
args = ["check-all-features", "--release"]
install_crate = "cargo-all-features"
[tasks.start-client]
command = "cargo"
args = ["leptos", "watch"]
[tasks.stop-client]
condition = { env_set = ["APP_PROCESS_NAME"] }
script = '''
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
pkill -f todo_app_sqlite
fi
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
pkill -f cargo-leptos
fi
'''
[tasks.client-status]
condition = { env_set = ["APP_PROCESS_NAME"] }
script = '''
if [ -z $(pidof ${APP_PROCESS_NAME}) ]; then
echo " ${APP_PROCESS_NAME} is not running"
else
echo " ${APP_PROCESS_NAME} is up"
fi
if [ -z $(pidof cargo-leptos) ]; then
echo " cargo-leptos is not running"
else
echo " cargo-leptos is up"
fi
'''

View File

@@ -0,0 +1,30 @@
[tasks.start-webdriver]
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
RED="\e[0;31m"
RESET="\e[0m"
if command -v chromedriver; then
if [ -z $(pidof chromedriver) ]; then
chromedriver --port=4444 &
fi
else
echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
exit 1
fi
'''
[tasks.stop-webdriver]
script = '''
pkill -f "chromedriver"
'''
[tasks.webdriver-status]
script = '''
if [ -z $(pidof chromedriver) ]; then
echo chromedriver is not running
else
echo chromedriver is up
fi
'''

View File

@@ -21,7 +21,7 @@ pub fn Nav(cx: Scope) -> impl IntoView {
<A href="/job">
<strong>"Jobs"</strong>
</A>
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
"Built with Leptos"
</a>
</nav>

View File

@@ -21,7 +21,7 @@ pub fn Nav(cx: Scope) -> impl IntoView {
<A href="/job">
<strong>"Jobs"</strong>
</A>
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
"Built with Leptos"
</a>
</nav>

View File

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

View File

@@ -1,17 +1,24 @@
extend = { path = "../cargo-make/main.toml" }
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/webdriver.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[tasks.check]
clear = true
dependencies = ["check-debug", "check-release"]
[env]
APP_PROCESS_NAME = "todo_app_sqlite"
[tasks.check-debug]
toolchain = "nightly"
[tasks.integration-test]
dependencies = [
"install-cargo-leptos",
"start-webdriver",
"test-e2e-with-auto-start",
]
[tasks.test-e2e-with-auto-start]
command = "cargo"
args = ["check-all-features"]
install_crate = "cargo-all-features"
args = ["leptos", "end-to-end"]
[tasks.check-release]
toolchain = "nightly"
[tasks.test-ui]
cwd = "./e2e"
command = "cargo"
args = ["check-all-features", "--release"]
install_crate = "cargo-all-features"
args = ["make", "test-ui", "${@}"]

View File

@@ -52,3 +52,23 @@ wasm-pack build --target=web --debug --no-default-features --features=hydrate
```bash
cargo run --no-default-features --features=ssr
```
## Testing
This example includes quality checks and end-to-end testing.
To get started run this once.
```bash
cargo make ci
```
To only run the UI tests...
```bash
cargo make start-webdriver
cargo leptos watch # or cargo run...
cargo make test-ui
```
_See the [E2E README](./e2e/README.md) for more information about the testing strategy._

Binary file not shown.

View File

@@ -0,0 +1,18 @@
[package]
name = "todo_app_sqlite_e2e"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
anyhow = "1.0.72"
async-trait = "0.1.72"
cucumber = "0.19.1"
fantoccini = "0.19.3"
pretty_assertions = "1.4.0"
serde_json = "1.0.104"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
url = "2.4.0"
[[test]]
name = "manage_todos"
harness = false # Allow Cucumber to print output instead of libtest

View File

@@ -0,0 +1,11 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.test]
env = { RUN_AUTOMATICALLY = false }
condition = { env_true = ["RUN_AUTOMATICALLY"] }
[tasks.ci]
[tasks.test-ui]
command = "cargo"
args = ["test", "--test", "manage_todos", "--", "--fail-fast", "${@}"]

View File

@@ -0,0 +1,33 @@
# E2E Testing
This example demonstrates e2e testing with Rust using executable requirements.
## Testing Stack
| | Role | Description |
|---|---|---|
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
## Testing Organization
Testing is organized around what a user can do and see/not see.
Here is a brief overview of how things fit together.
```bash
features # Specify test scenarios
tests
├── fixtures
│ ├── action.rs # Perform a user action (click, type, etc.)
│ ├── check.rs # Assert what a user can see/not see
│ ├── find.rs # Query page elements
│ ├── mod.rs
│ └── world
│ ├── action_steps.rs # Map Gherkin steps to user actions
│ ├── check_steps.rs # Map Gherkin steps to user expectations
│ └── mod.rs
└── manage_todos.rs # Test main
```

View File

@@ -0,0 +1,17 @@
@add_todo
Feature: Add Todo
Background:
Given I see the app
@add_todo-see
Scenario: Should see the todo
Given I set the todo as Buy Bread
When I click the Add button
Then I see the todo named Buy Bread
# @allow.skipped
@add_todo-style
Scenario: Should see the pending todo
When I add a todo as Buy Oranges
Then I see the pending todo

View File

@@ -0,0 +1,18 @@
@delete_todo
Feature: Delete Todo
Background:
Given I see the app
@serial
@delete_todo-remove
Scenario: Should not see the deleted todo
Given I add a todo as Buy Yogurt
When I delete the todo named Buy Yogurt
Then I do not see the todo named Buy Yogurt
@serial
@delete_todo-message
Scenario: Should see the empty list message
When I empty the todo list
Then I see the empty list message is No tasks were found.

View File

@@ -0,0 +1,12 @@
@open_app
Feature: Open App
@open_app-title
Scenario: Should see the home page title
When I open the app
Then I see the page title is My Tasks
@open_app-label
Scenario: Should see the input label
When I open the app
Then I see the label of the input is Add a Todo

View File

@@ -0,0 +1,60 @@
use super::{find, world::HOST};
use anyhow::Result;
use fantoccini::Client;
use std::result::Result::Ok;
use tokio::{self, time};
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
let url = format!("{}{}", HOST, path);
client.goto(&url).await?;
Ok(())
}
pub async fn add_todo(client: &Client, text: &str) -> Result<()> {
fill_todo(client, text).await?;
click_add_button(client).await?;
Ok(())
}
pub async fn fill_todo(client: &Client, text: &str) -> Result<()> {
let textbox = find::todo_input(client).await;
textbox.send_keys(text).await?;
Ok(())
}
pub async fn click_add_button(client: &Client) -> Result<()> {
let add_button = find::add_button(client).await;
add_button.click().await?;
Ok(())
}
pub async fn empty_todo_list(client: &Client) -> Result<()> {
let todos = find::todos(client).await;
for _todo in todos {
let _ = delete_first_todo(client).await?;
}
Ok(())
}
pub async fn delete_first_todo(client: &Client) -> Result<()> {
if let Some(element) = find::first_delete_button(client).await {
element.click().await.expect("Failed to delete todo");
time::sleep(time::Duration::from_millis(250)).await;
}
Ok(())
}
pub async fn delete_todo(client: &Client, text: &str) -> Result<()> {
if let Some(element) = find::delete_button(client, text).await {
element.click().await?;
time::sleep(time::Duration::from_millis(250)).await;
}
Ok(())
}

View File

@@ -0,0 +1,57 @@
use super::find;
use anyhow::{Ok, Result};
use fantoccini::{Client, Locator};
use pretty_assertions::assert_eq;
pub async fn text_on_element(
client: &Client,
selector: &str,
expected_text: &str,
) -> Result<()> {
let element = client
.wait()
.for_element(Locator::Css(selector))
.await
.expect(
format!("Element not found by Css selector `{}`", selector)
.as_str(),
);
let actual = element.text().await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn todo_present(
client: &Client,
text: &str,
expected: bool,
) -> Result<()> {
let todo_present = is_todo_present(client, text).await;
assert_eq!(todo_present, expected);
Ok(())
}
async fn is_todo_present(client: &Client, text: &str) -> bool {
let todos = find::todos(client).await;
for todo in todos {
let todo_title = todo.text().await.expect("Todo title not found");
if todo_title == text {
return true;
}
}
false
}
pub async fn todo_is_pending(client: &Client) -> Result<()> {
if let None = find::pending_todo(client).await {
assert!(false, "Pending todo not found");
}
Ok(())
}

View File

@@ -0,0 +1,63 @@
use fantoccini::{elements::Element, Client, Locator};
pub async fn todo_input(client: &Client) -> Element {
let textbox = client
.wait()
.for_element(Locator::Css("input[name='title"))
.await
.expect("Todo textbox not found");
textbox
}
pub async fn add_button(client: &Client) -> Element {
let button = client
.wait()
.for_element(Locator::Css("input[value='Add']"))
.await
.expect("");
button
}
pub async fn first_delete_button(client: &Client) -> Option<Element> {
if let Ok(element) = client
.wait()
.for_element(Locator::Css("li:first-child input[value='X']"))
.await
{
return Some(element);
}
None
}
pub async fn delete_button(client: &Client, text: &str) -> Option<Element> {
let selector = format!("//*[text()='{text}']//input[@value='X']");
if let Ok(element) =
client.wait().for_element(Locator::XPath(&selector)).await
{
return Some(element);
}
None
}
pub async fn pending_todo(client: &Client) -> Option<Element> {
if let Ok(element) =
client.wait().for_element(Locator::Css(".pending")).await
{
return Some(element);
}
None
}
pub async fn todos(client: &Client) -> Vec<Element> {
let todos = client
.find_all(Locator::Css("li"))
.await
.expect("Todo List not found");
todos
}

View File

@@ -0,0 +1,4 @@
pub mod action;
pub mod check;
pub mod find;
pub mod world;

View File

@@ -0,0 +1,57 @@
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::goto_path(client, "").await?;
Ok(())
}
#[given(regex = "^I add a todo as (.*)$")]
#[when(regex = "^I add a todo as (.*)$")]
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::add_todo(client, text.as_str()).await?;
Ok(())
}
#[given(regex = "^I set the todo as (.*)$")]
async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::fill_todo(client, &text).await?;
Ok(())
}
#[when(regex = "I click the Add button$")]
async fn i_click_the_button(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::click_add_button(client).await?;
Ok(())
}
#[when(regex = "^I delete the todo named (.*)$")]
async fn i_delete_the_todo_named(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
action::delete_todo(client, text.as_str()).await?;
Ok(())
}
#[given("the todo list is empty")]
#[when("I empty the todo list")]
async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::empty_todo_list(client).await?;
Ok(())
}

View File

@@ -0,0 +1,67 @@
use crate::fixtures::{check, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::then;
#[then(regex = "^I see the page title is (.*)$")]
async fn i_see_the_page_title_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "h1", &text).await?;
Ok(())
}
#[then(regex = "^I see the label of the input is (.*)$")]
async fn i_see_the_label_of_the_input_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "label", &text).await?;
Ok(())
}
#[then(regex = "^I see the todo named (.*)$")]
async fn i_see_the_todo_is_present(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::todo_present(client, text.as_str(), true).await?;
Ok(())
}
#[then("I see the pending todo")]
async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
check::todo_is_pending(client).await?;
Ok(())
}
#[then(regex = "^I see the empty list message is (.*)$")]
async fn i_see_the_empty_list_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "ul p", &text).await?;
Ok(())
}
#[then(regex = "^I do not see the todo named (.*)$")]
async fn i_do_not_see_the_todo_is_present(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::todo_present(client, text.as_str(), false).await?;
Ok(())
}

View File

@@ -0,0 +1,39 @@
pub mod action_steps;
pub mod check_steps;
use anyhow::Result;
use cucumber::World;
use fantoccini::{
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
};
pub const HOST: &str = "http://127.0.0.1:3000";
#[derive(Debug, World)]
#[world(init = Self::new)]
pub struct AppWorld {
pub client: Client,
}
impl AppWorld {
async fn new() -> Result<Self, anyhow::Error> {
let webdriver_client = build_client().await?;
Ok(Self {
client: webdriver_client,
})
}
}
async fn build_client() -> Result<Client, NewSessionError> {
let mut cap = Capabilities::new();
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
cap.insert("goog:chromeOptions".to_string(), arg);
let client = ClientBuilder::native()
.capabilities(cap)
.connect("http://localhost:4444")
.await?;
Ok(client)
}

View File

@@ -0,0 +1,14 @@
mod fixtures;
use anyhow::Result;
use cucumber::World;
use fixtures::world::AppWorld;
#[tokio::main]
async fn main() -> Result<()> {
AppWorld::cucumber()
.fail_on_skipped()
.run_and_exit("./features")
.await;
Ok(())
}

View File

@@ -1,5 +1,4 @@
use cfg_if::cfg_if;
use leptos::*;
mod todo;
// boilerplate to run in different modes
@@ -9,6 +8,7 @@ cfg_if! {
use actix_files::{Files};
use actix_web::*;
use crate::todo::*;
use leptos::*;
use leptos_actix::{generate_route_list, LeptosRoutes};
#[get("/style.css")]

View File

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

View File

@@ -1 +1,24 @@
extend = { path = "../cargo-make/main.toml" }
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/webdriver.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[env]
APP_PROCESS_NAME = "todo_app_sqlite_axum"
[tasks.integration-test]
dependencies = [
"install-cargo-leptos",
"start-webdriver",
"test-e2e-with-auto-start",
]
[tasks.test-e2e-with-auto-start]
command = "cargo"
args = ["leptos", "end-to-end"]
[tasks.test-ui]
cwd = "./e2e"
command = "cargo"
args = ["make", "test-ui", "${@}"]

View File

@@ -40,3 +40,23 @@ wasm-pack build --target=web --debug --no-default-features --features=hydrate
```bash
cargo run --no-default-features --features=ssr
```
## Testing
This example includes quality checks and end-to-end testing.
To get started run this once.
```bash
cargo make ci
```
To only run the UI tests...
```bash
cargo make start-webdriver
cargo leptos watch # or cargo run...
cargo make test-ui
```
_See the [E2E README](./e2e/README.md) for more information about the testing strategy._

View File

@@ -0,0 +1,18 @@
[package]
name = "todo_app_sqlite_axum_e2e"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
anyhow = "1.0.72"
async-trait = "0.1.72"
cucumber = "0.19.1"
fantoccini = "0.19.3"
pretty_assertions = "1.4.0"
serde_json = "1.0.104"
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
url = "2.4.0"
[[test]]
name = "manage_todos"
harness = false # Allow Cucumber to print output instead of libtest

View File

@@ -0,0 +1,11 @@
extend = { path = "../../cargo-make/main.toml" }
[tasks.test]
env = { RUN_AUTOMATICALLY = false }
condition = { env_true = ["RUN_AUTOMATICALLY"] }
[tasks.ci]
[tasks.test-ui]
command = "cargo"
args = ["test", "--test", "manage_todos", "--", "--fail-fast", "${@}"]

View File

@@ -0,0 +1,33 @@
# E2E Testing
This example demonstrates e2e testing with Rust using executable requirements.
## Testing Stack
| | Role | Description |
|---|---|---|
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
## Testing Organization
Testing is organized around what a user can do and see/not see.
Here is a brief overview of how things fit together.
```bash
features # Specify test scenarios
tests
├── fixtures
│ ├── action.rs # Perform a user action (click, type, etc.)
│ ├── check.rs # Assert what a user can see/not see
│ ├── find.rs # Query page elements
│ ├── mod.rs
│ └── world
│ ├── action_steps.rs # Map Gherkin steps to user actions
│ ├── check_steps.rs # Map Gherkin steps to user expectations
│ └── mod.rs
└── manage_todos.rs # Test main
```

View File

@@ -0,0 +1,16 @@
@add_todo
Feature: Add Todo
Background:
Given I see the app
@add_todo-see
Scenario: Should see the todo
Given I set the todo as Buy Bread
When I click the Add button
Then I see the todo named Buy Bread
@add_todo-style
Scenario: Should see the pending todo
When I add a todo as Buy Oranges
Then I see the pending todo

View File

@@ -0,0 +1,18 @@
@delete_todo
Feature: Delete Todo
Background:
Given I see the app
@serial
@delete_todo-remove
Scenario: Should not see the deleted todo
Given I add a todo as Buy Yogurt
When I delete the todo named Buy Yogurt
Then I do not see the todo named Buy Yogurt
@serial
@delete_todo-message
Scenario: Should see the empty list message
When I empty the todo list
Then I see the empty list message is No tasks were found.

View File

@@ -0,0 +1,12 @@
@open_app
Feature: Open App
@open_app-title
Scenario: Should see the home page title
When I open the app
Then I see the page title is My Tasks
@open_app-label
Scenario: Should see the input label
When I open the app
Then I see the label of the input is Add a Todo

View File

@@ -0,0 +1,60 @@
use super::{find, world::HOST};
use anyhow::Result;
use fantoccini::Client;
use std::result::Result::Ok;
use tokio::{self, time};
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
let url = format!("{}{}", HOST, path);
client.goto(&url).await?;
Ok(())
}
pub async fn add_todo(client: &Client, text: &str) -> Result<()> {
fill_todo(client, text).await?;
click_add_button(client).await?;
Ok(())
}
pub async fn fill_todo(client: &Client, text: &str) -> Result<()> {
let textbox = find::todo_input(client).await;
textbox.send_keys(text).await?;
Ok(())
}
pub async fn click_add_button(client: &Client) -> Result<()> {
let add_button = find::add_button(client).await;
add_button.click().await?;
Ok(())
}
pub async fn empty_todo_list(client: &Client) -> Result<()> {
let todos = find::todos(client).await;
for _todo in todos {
let _ = delete_first_todo(client).await?;
}
Ok(())
}
pub async fn delete_first_todo(client: &Client) -> Result<()> {
if let Some(element) = find::first_delete_button(client).await {
element.click().await.expect("Failed to delete todo");
time::sleep(time::Duration::from_millis(250)).await;
}
Ok(())
}
pub async fn delete_todo(client: &Client, text: &str) -> Result<()> {
if let Some(element) = find::delete_button(client, text).await {
element.click().await?;
time::sleep(time::Duration::from_millis(250)).await;
}
Ok(())
}

View File

@@ -0,0 +1,57 @@
use super::find;
use anyhow::{Ok, Result};
use fantoccini::{Client, Locator};
use pretty_assertions::assert_eq;
pub async fn text_on_element(
client: &Client,
selector: &str,
expected_text: &str,
) -> Result<()> {
let element = client
.wait()
.for_element(Locator::Css(selector))
.await
.expect(
format!("Element not found by Css selector `{}`", selector)
.as_str(),
);
let actual = element.text().await?;
assert_eq!(&actual, expected_text);
Ok(())
}
pub async fn todo_present(
client: &Client,
text: &str,
expected: bool,
) -> Result<()> {
let todo_present = is_todo_present(client, text).await;
assert_eq!(todo_present, expected);
Ok(())
}
async fn is_todo_present(client: &Client, text: &str) -> bool {
let todos = find::todos(client).await;
for todo in todos {
let todo_title = todo.text().await.expect("Todo title not found");
if todo_title == text {
return true;
}
}
false
}
pub async fn todo_is_pending(client: &Client) -> Result<()> {
if let None = find::pending_todo(client).await {
assert!(false, "Pending todo not found");
}
Ok(())
}

View File

@@ -0,0 +1,63 @@
use fantoccini::{elements::Element, Client, Locator};
pub async fn todo_input(client: &Client) -> Element {
let textbox = client
.wait()
.for_element(Locator::Css("input[name='title"))
.await
.expect("Todo textbox not found");
textbox
}
pub async fn add_button(client: &Client) -> Element {
let button = client
.wait()
.for_element(Locator::Css("input[value='Add']"))
.await
.expect("");
button
}
pub async fn first_delete_button(client: &Client) -> Option<Element> {
if let Ok(element) = client
.wait()
.for_element(Locator::Css("li:first-child input[value='X']"))
.await
{
return Some(element);
}
None
}
pub async fn delete_button(client: &Client, text: &str) -> Option<Element> {
let selector = format!("//*[text()='{text}']//input[@value='X']");
if let Ok(element) =
client.wait().for_element(Locator::XPath(&selector)).await
{
return Some(element);
}
None
}
pub async fn pending_todo(client: &Client) -> Option<Element> {
if let Ok(element) =
client.wait().for_element(Locator::Css(".pending")).await
{
return Some(element);
}
None
}
pub async fn todos(client: &Client) -> Vec<Element> {
let todos = client
.find_all(Locator::Css("li"))
.await
.expect("Todo List not found");
todos
}

View File

@@ -0,0 +1,4 @@
pub mod action;
pub mod check;
pub mod find;
pub mod world;

View File

@@ -0,0 +1,57 @@
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::goto_path(client, "").await?;
Ok(())
}
#[given(regex = "^I add a todo as (.*)$")]
#[when(regex = "^I add a todo as (.*)$")]
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::add_todo(client, text.as_str()).await?;
Ok(())
}
#[given(regex = "^I set the todo as (.*)$")]
async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::fill_todo(client, &text).await?;
Ok(())
}
#[when(regex = "I click the Add button$")]
async fn i_click_the_button(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::click_add_button(client).await?;
Ok(())
}
#[when(regex = "^I delete the todo named (.*)$")]
async fn i_delete_the_todo_named(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
action::delete_todo(client, text.as_str()).await?;
Ok(())
}
#[given("the todo list is empty")]
#[when("I empty the todo list")]
async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::empty_todo_list(client).await?;
Ok(())
}

View File

@@ -0,0 +1,67 @@
use crate::fixtures::{check, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::then;
#[then(regex = "^I see the page title is (.*)$")]
async fn i_see_the_page_title_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "h1", &text).await?;
Ok(())
}
#[then(regex = "^I see the label of the input is (.*)$")]
async fn i_see_the_label_of_the_input_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "label", &text).await?;
Ok(())
}
#[then(regex = "^I see the todo named (.*)$")]
async fn i_see_the_todo_is_present(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::todo_present(client, text.as_str(), true).await?;
Ok(())
}
#[then("I see the pending todo")]
async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
check::todo_is_pending(client).await?;
Ok(())
}
#[then(regex = "^I see the empty list message is (.*)$")]
async fn i_see_the_empty_list_message_is(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::text_on_element(client, "ul p", &text).await?;
Ok(())
}
#[then(regex = "^I do not see the todo named (.*)$")]
async fn i_do_not_see_the_todo_is_present(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::todo_present(client, text.as_str(), false).await?;
Ok(())
}

View File

@@ -0,0 +1,39 @@
pub mod action_steps;
pub mod check_steps;
use anyhow::Result;
use cucumber::World;
use fantoccini::{
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
};
pub const HOST: &str = "http://127.0.0.1:3000";
#[derive(Debug, World)]
#[world(init = Self::new)]
pub struct AppWorld {
pub client: Client,
}
impl AppWorld {
async fn new() -> Result<Self, anyhow::Error> {
let webdriver_client = build_client().await?;
Ok(Self {
client: webdriver_client,
})
}
}
async fn build_client() -> Result<Client, NewSessionError> {
let mut cap = Capabilities::new();
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
cap.insert("goog:chromeOptions".to_string(), arg);
let client = ClientBuilder::native()
.capabilities(cap)
.connect("http://localhost:4444")
.await?;
Ok(client)
}

View File

@@ -0,0 +1,14 @@
mod fixtures;
use anyhow::Result;
use cucumber::World;
use fixtures::world::AppWorld;
#[tokio::main]
async fn main() -> Result<()> {
AppWorld::cucumber()
.fail_on_skipped()
.run_and_exit("./features")
.await;
Ok(())
}

View File

@@ -13,7 +13,7 @@ cfg_if! {
#[wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
_ = console_log::init_with_level(log::Level::Error);
console_error_panic_hook::set_once();
leptos::mount_to_body(|cx| {

View File

@@ -29,7 +29,7 @@ cfg_if! {
#[tokio::main]
async fn main() {
simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging");
simple_logger::init_with_level(log::Level::Error).expect("couldn't initialize logging");
let _conn = db().await.expect("couldn't connect to DB");
/* sqlx::migrate!()

View File

@@ -14,7 +14,7 @@ fn autoreload(nonce_str: &str, options: &LeptosOptions) -> String {
r#"
<script crossorigin=""{nonce_str}>(function () {{
{}
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
let ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
ws.onmessage = (ev) => {{
let msg = JSON.parse(ev.data);
if (msg.all) window.location.reload();

View File

@@ -4,7 +4,7 @@ version = { workspace = true }
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/gbj/leptos"
repository = "https://github.com/leptos-rs/leptos"
description = "Leptos is a full-stack, isomorphic Rust web framework leveraging fine-grained reactivity to build declarative user interfaces."
readme = "../README.md"

View File

@@ -52,12 +52,12 @@ pub fn App(cx: Scope) -> impl IntoView {
<Outlet/>
}
>
<Route path="" view=Nested
<Route path="inside" view=NestedResourceInside
<Route path="single" view=Single
<Route path="parallel" view=Parallel
<Route path="inside-component" view=InsideComponent
<Route path="none" view=None
<Route path="" view=Nested/>
<Route path="inside" view=NestedResourceInside/>
<Route path="single" view=Single/>
<Route path="parallel" view=Parallel/>
<Route path="inside-component" view=InsideComponent/>
<Route path="none" view=None/>
</Route>
// in-order
<Route
@@ -69,12 +69,12 @@ pub fn App(cx: Scope) -> impl IntoView {
<Outlet/>
}
>
<Route path="" view=Nested
<Route path="inside" view=NestedResourceInside
<Route path="single" view=Single
<Route path="parallel" view=Parallel
<Route path="inside-component" view=InsideComponent
<Route path="none" view=None
<Route path="" view=Nested/>
<Route path="inside" view=NestedResourceInside/>
<Route path="single" view=Single/>
<Route path="parallel" view=Parallel/>
<Route path="inside-component" view=InsideComponent/>
<Route path="none" view=None/>
</Route>
// async
<Route
@@ -86,12 +86,12 @@ pub fn App(cx: Scope) -> impl IntoView {
<Outlet/>
}
>
<Route path="" view=Nested
<Route path="inside" view=NestedResourceInside
<Route path="single" view=Single
<Route path="parallel" view=Parallel
<Route path="inside-component" view=InsideComponent
<Route path="none" view=None
<Route path="" view=Nested/>
<Route path="inside" view=NestedResourceInside/>
<Route path="single" view=Single/>
<Route path="parallel" view=Parallel/>
<Route path="inside-component" view=InsideComponent/>
<Route path="none" view=None/>
</Route>
</Routes>
</main>

View File

@@ -7,7 +7,7 @@ use leptos_reactive::Scope;
use std::{borrow::Cow, cell::RefCell, fmt, ops::Deref, rc::Rc};
cfg_if! {
if #[cfg(all(target_arch = "wasm32", feature = "web"))] {
use crate::{mount_child, prepare_to_move, unmount_child, MountKind, Mountable};
use crate::{mount_child, prepare_to_move, unmount_child, MountKind, Mountable, Text};
use leptos_reactive::{create_effect, ScopeDisposer};
use wasm_bindgen::JsCast;
}
@@ -347,32 +347,29 @@ where
}
// Otherwise, we know for sure this is our first time
else {
// We need to remove the text created from SSR
if HydrationCtx::is_hydrating()
// If it's a text node, we want to use the old text node
// as the text node for the DynChild, rather than the new
// text node being created during hydration
let new_child = if HydrationCtx::is_hydrating()
&& new_child.get_text().is_some()
{
let t = closing
.previous_non_view_marker_sibling()
.unwrap()
.unchecked_into::<web_sys::Element>();
.unchecked_into::<web_sys::Text>();
// See note on ssr.rs when matching on `DynChild`
// for more details on why we need to do this for
// release
if !cfg!(debug_assertions) {
t.previous_sibling()
.unwrap()
.unchecked_into::<web_sys::Element>()
.remove();
}
t.remove();
mount_child(
MountKind::Before(&closing),
&new_child,
);
}
let new_child = match new_child {
View::Text(text) => text,
_ => unreachable!(),
};
t.set_data(&new_child.content);
View::Text(Text {
node: t.unchecked_into(),
content: new_child.content,
})
} else {
new_child
};
// If we are not hydrating, we simply mount the child
if !HydrationCtx::is_hydrating() {

View File

@@ -357,10 +357,10 @@ fn fragments_to_chunks(
r#"
<template id="{fragment_id}f">{html}</template>
<script{nonce_str}>
var id = "{fragment_id}";
var open = undefined;
var close = undefined;
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
(function() {{ let id = "{fragment_id}";
let open = undefined;
let close = undefined;
let walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
while(walker.nextNode()) {{
if(walker.currentNode.textContent == `suspense-open-${{id}}`) {{
open = walker.currentNode;
@@ -368,12 +368,12 @@ fn fragments_to_chunks(
close = walker.currentNode;
}}
}}
var range = new Range();
let range = new Range();
range.setStartAfter(open);
range.setEndBefore(close);
range.deleteContents();
var tpl = document.getElementById("{fragment_id}f");
close.parentNode.insertBefore(tpl.content.cloneNode(true), close);
let tpl = document.getElementById("{fragment_id}f");
close.parentNode.insertBefore(tpl.content.cloneNode(true), close);}})()
</script>
"#
)
@@ -483,26 +483,36 @@ impl View {
true,
Box::new(move || {
if let Some(child) = *child {
// On debug builds, `DynChild` has two marker nodes,
// so there is no way for the text to be merged with
// surrounding text when the browser parses the HTML,
// but in release, `DynChild` only has a trailing marker,
// and the browser automatically merges the dynamic text
// into one single node, so we need to artificially make the
// browser create the dynamic text as it's own text node
if let View::Text(t) = child {
if !cfg!(debug_assertions) {
format!(
"<!>{}",
html_escape::encode_safe(
&t.content
)
)
.into()
// if we don't check if the string is empty,
// the HTML is an empty string; but an empty string
// is not a text node in HTML, so can't be updated
// in the future. so we put a one-space text node instead
let was_empty = t.content.is_empty();
let content = if was_empty {
" ".into()
} else {
html_escape::encode_safe(&t.content)
t.content
};
// escape content unless we're in a <script> or <style>
let content = if dont_escape_text {
content
} else {
html_escape::encode_safe(&content)
.to_string()
.into()
};
// On debug builds, `DynChild` has two marker nodes,
// so there is no way for the text to be merged with
// surrounding text when the browser parses the HTML,
// but in release, `DynChild` only has a trailing marker,
// and the browser automatically merges the dynamic text
// into one single node, so we need to artificially make the
// browser create the dynamic text as it's own text node
if !cfg!(debug_assertions) {
format!("<!>{content}",).into()
} else {
content
}
} else {
child.render_to_string_helper(
@@ -718,12 +728,12 @@ pub(crate) fn render_serializers(
let json = json.replace('<', "\\u003c");
format!(
r#"<script{nonce_str}>
var val = {json:?};
(function() {{ let val = {json:?};
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
__LEPTOS_RESOURCE_RESOLVERS.get({id})(val)
}} else {{
__LEPTOS_RESOLVED_RESOURCES.set({id}, val);
}}
}} }})();
</script>"#,
)
})

View File

@@ -416,23 +416,35 @@ impl View {
Box::new(
move |chunks: &mut VecDeque<StreamChunk>| {
if let Some(child) = *child {
// On debug builds, `DynChild` has two marker nodes,
// so there is no way for the text to be merged with
// surrounding text when the browser parses the HTML,
// but in release, `DynChild` only has a trailing marker,
// and the browser automatically merges the dynamic text
// into one single node, so we need to artificially make the
// browser create the dynamic text as it's own text node
if let View::Text(t) = child {
let content = if dont_escape_text {
// if we don't check if the string is empty,
// the HTML is an empty string; but an empty string
// is not a text node in HTML, so can't be updated
// in the future. so we put a one-space text node instead
let was_empty =
t.content.is_empty();
let content = if was_empty {
" ".into()
} else {
t.content
};
// escape content unless we're in a <script> or <style>
let content = if dont_escape_text {
content
} else {
html_escape::encode_safe(
&t.content,
&content,
)
.to_string()
.into()
};
// On debug builds, `DynChild` has two marker nodes,
// so there is no way for the text to be merged with
// surrounding text when the browser parses the HTML,
// but in release, `DynChild` only has a trailing marker,
// and the browser automatically merges the dynamic text
// into one single node, so we need to artificially make the
// browser create the dynamic text as it's own text node
chunks.push_back(
if !cfg!(debug_assertions) {
StreamChunk::Sync(

View File

@@ -33,6 +33,7 @@ log = "0.4"
typed-builder = "0.14"
trybuild = "1"
leptos = { path = "../leptos" }
insta = "1.29"
[features]
csr = []

View File

@@ -32,11 +32,9 @@ impl Default for Mode {
mod params;
mod view;
use template::render_template;
use view::render_view;
use view::{client_template::render_template, render_view};
mod component;
mod slot;
mod template;
/// The `view` macro uses RSX (like JSX, but Rust!) It follows most of the
/// same rules as HTML, with the following differences:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,511 @@
use super::{
component_builder::component_to_tokens,
expr_to_ident, fancy_class_name, fancy_style_name,
ide_helper::IdeTagHelper,
is_ambiguous_element, is_custom_element, is_math_ml_element,
is_svg_element, parse_event_name,
slot_helper::{get_slot, slot_to_tokens},
};
use crate::attribute_value;
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{quote, quote_spanned};
use rstml::node::{KeyedAttribute, Node, NodeAttribute, NodeElement, NodeName};
use std::collections::HashMap;
use syn::spanned::Spanned;
#[derive(Clone, Copy)]
pub(crate) enum TagType {
Unknown,
Html,
Svg,
Math,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn fragment_to_tokens(
cx: &Ident,
_span: Span,
nodes: &[Node],
lazy: bool,
parent_type: TagType,
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> Option<TokenStream> {
let mut slots = HashMap::new();
let has_slots = parent_slots.is_some();
let mut nodes = nodes
.iter()
.filter_map(|node| {
let node = node_to_tokens(
cx,
node,
parent_type,
has_slots.then_some(&mut slots),
global_class,
None,
)?;
Some(quote! {
#node.into_view(#cx)
})
})
.peekable();
if nodes.peek().is_none() {
_ = nodes.collect::<Vec<_>>();
if let Some(parent_slots) = parent_slots {
for (slot, mut values) in slots.drain() {
parent_slots
.entry(slot)
.and_modify(|entry| entry.append(&mut values))
.or_insert(values);
}
}
return None;
}
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
let tokens = if lazy {
quote! {
{
::leptos::Fragment::lazy(|| [
#(#nodes),*
].to_vec())
#view_marker
}
}
} else {
quote! {
{
::leptos::Fragment::new([
#(#nodes),*
].to_vec())
#view_marker
}
}
};
if let Some(parent_slots) = parent_slots {
for (slot, mut values) in slots.drain() {
parent_slots
.entry(slot)
.and_modify(|entry| entry.append(&mut values))
.or_insert(values);
}
}
Some(tokens)
}
pub(crate) fn node_to_tokens(
cx: &Ident,
node: &Node,
parent_type: TagType,
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> Option<TokenStream> {
match node {
Node::Fragment(fragment) => fragment_to_tokens(
cx,
Span::call_site(),
&fragment.children,
true,
parent_type,
None,
global_class,
view_marker,
),
Node::Comment(_) | Node::Doctype(_) => Some(quote! {}),
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,
node,
parent_type,
parent_slots,
global_class,
view_marker,
),
}
}
pub(crate) fn element_to_tokens(
cx: &Ident,
node: &NodeElement,
mut parent_type: TagType,
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
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);
None
} else {
Some(component_to_tokens(cx, node, global_class))
}
} else {
let tag = name.to_string();
// collect close_tag name to emit semantic information for IDE.
let mut ide_helper_close_tag = IdeTagHelper::new();
let close_tag = node.close_tag.as_ref().map(|c| &c.name);
let name = if is_custom_element(&tag) {
let name = node.name().to_string();
// link custom ident to name span for IDE docs
let custom = Ident::new("custom", name.span());
quote! { ::leptos::leptos_dom::html::#custom(#cx, ::leptos::leptos_dom::html::Custom::new(#name)) }
} else if is_svg_element(&tag) {
parent_type = TagType::Svg;
quote! { ::leptos::leptos_dom::svg::#name(#cx) }
} else if is_math_ml_element(&tag) {
parent_type = TagType::Math;
quote! { ::leptos::leptos_dom::math::#name(#cx) }
} else if is_ambiguous_element(&tag) {
match parent_type {
TagType::Unknown => {
// We decided this warning was too aggressive, but I'll leave it here in case we want it later
/* proc_macro_error::emit_warning!(name.span(), "The view macro is assuming this is an HTML element, \
but it is ambiguous; if it is an SVG or MathML element, prefix with svg:: or math::"); */
quote! {
::leptos::leptos_dom::html::#name(#cx)
}
}
TagType::Html => {
quote! { ::leptos::leptos_dom::html::#name(#cx) }
}
TagType::Svg => {
quote! { ::leptos::leptos_dom::svg::#name(#cx) }
}
TagType::Math => {
quote! { ::leptos::leptos_dom::math::#name(#cx) }
}
}
} else {
parent_type = TagType::Html;
quote! { ::leptos::leptos_dom::html::#name(#cx) }
};
if let Some(close_tag) = close_tag {
ide_helper_close_tag.save_tag_completion(close_tag)
}
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:")
|| fancy_class_name(name, cx, node).is_some()
|| name.starts_with("style:")
|| fancy_style_name(name, cx, node).is_some()
{
None
} else {
Some(attribute_to_tokens(cx, node, global_class))
}
} else {
None
}
});
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)
} else if name.trim().starts_with("class:") {
Some(attribute_to_tokens(cx, node, global_class))
} else {
None
}
} else {
None
}
});
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)
} else if name.trim().starts_with("style:") {
Some(attribute_to_tokens(cx, node, global_class))
} else {
None
}
} else {
None
}
});
let global_class_expr = match global_class {
None => quote! {},
Some(class) => {
quote! {
.classes(
#[allow(unused_braces)]
#class
)
}
}
};
let children = node.children.iter().map(|node| {
let (child, is_static) = match node {
Node::Fragment(fragment) => (
fragment_to_tokens(
cx,
Span::call_site(),
&fragment.children,
true,
parent_type,
None,
global_class,
None,
)
.unwrap_or({
let span = Span::call_site();
quote_spanned! {
span => ::leptos::leptos_dom::Unit
}
}),
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,
node,
parent_type,
None,
global_class,
None,
)
.unwrap_or_default(),
false,
),
Node::Comment(_) | Node::Doctype(_) => (quote! {}, false),
};
if is_static {
quote! {
.child(#child)
}
} else {
quote! {
.child((#cx, #child))
}
}
});
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
let ide_helper_close_tag = ide_helper_close_tag.into_iter();
Some(quote! {
{
#(#ide_helper_close_tag)*
#name
#(#attrs)*
#(#class_attrs)*
#(#style_attrs)*
#global_class_expr
#(#children)*
#view_marker
}
})
}
}
pub(crate) fn attribute_to_tokens(
cx: &Ident,
node: &KeyedAttribute,
global_class: Option<&TokenTree>,
) -> TokenStream {
let span = node.key.span();
let name = node.key.to_string();
if name == "ref" || name == "_ref" || name == "ref_" || name == "node_ref" {
let value = expr_to_ident(attribute_value(node));
let node_ref = quote_spanned! { span => node_ref };
quote! {
.#node_ref(#value)
}
} else if let Some(name) = name.strip_prefix("on:") {
let handler = attribute_value(node);
let (event_type, is_custom, is_force_undelegated) =
parse_event_name(name);
let event_name_ident = match &node.key {
NodeName::Punctuated(parts) => {
if parts.len() >= 2 {
Some(&parts[1])
} else {
None
}
}
_ => unreachable!(),
};
let undelegated_ident = match &node.key {
NodeName::Punctuated(parts) => parts.last().and_then(|last| {
if last.to_string() == "undelegated" {
Some(last)
} else {
None
}
}),
_ => unreachable!(),
};
let on = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
};
let on = {
let span = on.span();
quote_spanned! {
span => .on
}
};
let event_type = if is_custom {
event_type
} else if let Some(ev_name) = event_name_ident {
let span = ev_name.span();
quote_spanned! {
span => #ev_name
}
} else {
event_type
};
let event_type = if is_force_undelegated {
let undelegated = if let Some(undelegated) = undelegated_ident {
let span = undelegated.span();
quote_spanned! {
span => #undelegated
}
} else {
quote! { undelegated }
};
quote! { ::leptos::ev::#undelegated(::leptos::ev::#event_type) }
} else {
quote! { ::leptos::ev::#event_type }
};
quote! {
#on(#event_type, #handler)
}
} else if let Some(name) = name.strip_prefix("prop:") {
let value = attribute_value(node);
let prop = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
};
let prop = {
let span = prop.span();
quote_spanned! {
span => .prop
}
};
quote! {
#prop(#name, (#cx, #[allow(unused_braces)] #value))
}
} else if let Some(name) = name.strip_prefix("class:") {
let value = attribute_value(node);
let class = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
};
let class = {
let span = class.span();
quote_spanned! {
span => .class
}
};
quote! {
#class(#name, (#cx, #[allow(unused_braces)] #value))
}
} else if let Some(name) = name.strip_prefix("style:") {
let value = attribute_value(node);
let style = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
};
let style = {
let span = style.span();
quote_spanned! {
span => .style
}
};
quote! {
#style(#name, (#cx, #[allow(unused_braces)] #value))
}
} else {
let name = name.replacen("attr:", "", 1);
if let Some((fancy, _, _)) = fancy_class_name(&name, cx, node) {
return fancy;
}
// special case of global_class and class attribute
if name == "class"
&& global_class.is_some()
&& 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 = ... }) \
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 \
for more information and an example: https://github.com/leptos-rs/leptos/issues/773")
};
// all other attributes
let value = match node.value() {
Some(value) => {
quote! { #value }
}
None => quote_spanned! { span => "" },
};
let attr = match &node.key {
NodeName::Punctuated(parts) => Some(&parts[0]),
_ => None,
};
let attr = if let Some(attr) = attr {
let span = attr.span();
quote_spanned! {
span => .attr
}
} else {
quote! {
.attr
}
};
quote! {
#attr(#name, (#cx, #value))
}
}
}

View File

@@ -1,4 +1,5 @@
use crate::{attribute_value, view::IdeTagHelper};
use super::{component_builder::component_to_tokens, IdeTagHelper};
use crate::attribute_value;
use itertools::Either;
use leptos_hot_reload::parsing::{
block_to_primitive_expression, is_component_node, value_to_string,
@@ -9,13 +10,10 @@ use rstml::node::{
KeyedAttribute, Node, NodeAttribute, NodeBlock, NodeElement,
};
use syn::spanned::Spanned;
use uuid::Uuid;
pub(crate) fn render_template(cx: &Ident, nodes: &[Node]) -> TokenStream {
let template_uid = Ident::new(
&format!("TEMPLATE_{}", Uuid::new_v4().simple()),
Span::call_site(),
);
// No reason to make template unique, because its "static" is in inner scope.
let template_uid = Ident::new("__TEMPLATE", Span::call_site());
match nodes.first() {
Some(Node::Element(node)) => {
@@ -36,7 +34,7 @@ fn root_element_to_tokens(
let mut expressions = Vec::new();
if is_component_node(node) {
crate::view::component_to_tokens(cx, node, None)
component_to_tokens(cx, node, None)
} else {
element_to_tokens(
cx,
@@ -65,11 +63,11 @@ fn root_element_to_tokens(
quote! {
{
thread_local! {
static #template_uid: leptos::web_sys::HtmlTemplateElement = {
let document = leptos::document();
static #template_uid: ::leptos::web_sys::HtmlTemplateElement = {
let document = ::leptos::document();
let el = document.create_element("template").unwrap();
el.set_inner_html(#template);
leptos::wasm_bindgen::JsCast::unchecked_into(el)
::leptos::wasm_bindgen::JsCast::unchecked_into(el)
}
}
@@ -79,10 +77,10 @@ fn root_element_to_tokens(
#(#navigations)*
#(#expressions;)*
leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
::leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
#[cfg(debug_assertions)]
name: #tag_name.into(),
element: leptos::wasm_bindgen::JsCast::unchecked_into(root),
element: ::leptos::wasm_bindgen::JsCast::unchecked_into(root),
#[cfg(debug_assertions)]
view_marker: None
})
@@ -149,7 +147,7 @@ fn element_to_tokens(
quote_spanned! {
span => let #this_el_ident = #debug_name;
let #this_el_ident =
leptos::wasm_bindgen::JsCast::unchecked_into::<leptos::web_sys::Node>(#parent.clone());
::leptos::wasm_bindgen::JsCast::unchecked_into::<leptos::web_sys::Node>(#parent.clone());
//debug!("=> got {}", #this_el_ident.node_name());
}
} else if let Some(prev_sib) = &prev_sib {
@@ -302,7 +300,7 @@ fn attr_to_tokens(
let (event_type, handler) =
crate::view::event_from_attribute_node(node, false);
expressions.push(quote! {
leptos::leptos_dom::add_event_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #event_type, #handler);
::leptos::leptos_dom::add_event_helper(::leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #event_type, #handler);
})
}
// Properties
@@ -310,7 +308,7 @@ fn attr_to_tokens(
let value = attribute_value(node);
expressions.push(quote_spanned! {
span => leptos::leptos_dom::property(#cx, leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name, #value.into_property(#cx))
span => ::leptos::leptos_dom::property(#cx, ::leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name, #value.into_property(#cx))
});
}
// Classes
@@ -318,7 +316,7 @@ fn attr_to_tokens(
let value = attribute_value(node);
expressions.push(quote_spanned! {
span => leptos::leptos_dom::class_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), #value.into_class(#cx))
span => ::leptos::leptos_dom::class_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), #value.into_class(#cx))
});
}
// Attributes
@@ -342,7 +340,7 @@ fn attr_to_tokens(
// For client-side rendering, dynamic attributes don't need to be rendered in the template
// They'll immediately be set synchronously before the cloned template is mounted
expressions.push(quote_spanned! {
span => leptos::leptos_dom::attribute_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), {#value}.into_attribute(#cx))
span => ::leptos::leptos_dom::attribute_helper(leptos::wasm_bindgen::JsCast::unchecked_ref(&#el_id), #name.into(), {#value}.into_attribute(#cx))
});
}
}
@@ -493,10 +491,10 @@ fn block_to_tokens(
let mount_kind = match &next_sib {
Some(child) => {
quote! { leptos::leptos_dom::MountKind::Before(&#child.clone()) }
quote! { ::leptos::leptos_dom::MountKind::Before(&#child.clone()) }
}
None => {
quote! { leptos::leptos_dom::MountKind::Append(&#parent) }
quote! { ::leptos::leptos_dom::MountKind::Append(&#parent) }
}
};
@@ -516,7 +514,7 @@ fn block_to_tokens(
navigations.push(location);
expressions.push(quote! {
leptos::leptos_dom::mount_child(#mount_kind, &{#value}.into_view(cx));
::leptos::leptos_dom::mount_child(#mount_kind, &{#value}.into_view(cx));
});
if let Some(name) = name {

View File

@@ -0,0 +1,176 @@
use super::{
client_builder::{fragment_to_tokens, TagType},
event_from_attribute_node, ident_from_tag_name,
};
use proc_macro2::{Ident, TokenStream, TokenTree};
use quote::{format_ident, quote};
use rstml::node::{NodeAttribute, NodeElement};
use std::collections::HashMap;
use syn::spanned::Spanned;
pub(crate) fn component_to_tokens(
cx: &Ident,
node: &NodeElement,
global_class: Option<&TokenTree>,
) -> TokenStream {
let name = node.name();
#[cfg(debug_assertions)]
let component_name = ident_from_tag_name(node.name());
let span = node.name().span();
let attrs = node.attributes().iter().filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
Some(node)
} else {
None
}
});
let props = attrs
.clone()
.filter(|attr| {
!attr.key.to_string().starts_with("bind:")
&& !attr.key.to_string().starts_with("clone:")
&& !attr.key.to_string().starts_with("on:")
})
.map(|attr| {
let name = &attr.key;
let value = attr
.value()
.map(|v| {
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
quote! {
.#name(#[allow(unused_braces)] #value)
}
});
let items_to_bind = attrs
.clone()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("bind:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let items_to_clone = attrs
.clone()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("clone:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let events = attrs
.filter(|attr| attr.key.to_string().starts_with("on:"))
.map(|attr| {
let (event_type, handler) = event_from_attribute_node(attr, true);
quote! {
.on(#event_type, #handler)
}
})
.collect::<Vec<_>>();
let mut slots = HashMap::new();
let children = if node.children.is_empty() {
quote! {}
} else {
cfg_if::cfg_if! {
if #[cfg(debug_assertions)] {
let marker = format!("<{component_name}/>-children");
let view_marker = quote! { .with_view_marker(#marker) };
} else {
let view_marker = quote! {};
}
}
let children = fragment_to_tokens(
cx,
span,
&node.children,
true,
TagType::Unknown,
Some(&mut slots),
global_class,
None,
);
if let Some(children) = children {
let bindables =
items_to_bind.iter().map(|ident| quote! { #ident, });
let clonables = items_to_clone
.iter()
.map(|ident| quote! { let #ident = #ident.clone(); });
if bindables.len() > 0 {
quote! {
.children({
#(#clonables)*
move |#cx, #(#bindables)*| #children #view_marker
})
}
} else {
quote! {
.children({
#(#clonables)*
Box::new(move |#cx| #children #view_marker)
})
}
}
} else {
quote! {}
}
};
let slots = slots.drain().map(|(slot, values)| {
let slot = Ident::new(&slot, span);
if values.len() > 1 {
quote! {
.#slot(vec![
#(#values)*
])
}
} else {
let value = &values[0];
quote! { .#slot(#value) }
}
});
#[allow(unused_mut)] // used in debug
let mut component = quote! {
::leptos::component_view(
&#name,
#cx,
::leptos::component_props_builder(&#name)
#(#props)*
#(#slots)*
#children
.build()
)
};
// (Temporarily?) removed
// See note on the function itself below.
/* #[cfg(debug_assertions)]
IdeTagHelper::add_component_completion(cx, &mut component, node); */
if events.is_empty() {
component
} else {
quote! {
#component.into_view(#cx)
#(#events)*
}
}
}

View File

@@ -0,0 +1,152 @@
use leptos_hot_reload::parsing::is_component_tag_name;
use proc_macro2::{Ident, Span, TokenStream};
use quote::quote;
use rstml::node::{NodeElement, NodeName};
use syn::spanned::Spanned;
/// Helper type to emit semantic info about tags, for IDE.
/// Implement `IntoIterator` with `Item="let _ = foo::docs;"`.
///
/// `IdeTagHelper` uses warning instead of errors everywhere,
/// it's aim is to add usability, not introduce additional typecheck in `view`/`template` code.
/// On stable `emit_warning` don't produce anything.
pub(crate) struct IdeTagHelper(Vec<TokenStream>);
// TODO: Unhandled cases:
// - svg::div, my_elements::foo - tags with custom paths, that doesnt look like component
// - my_component::Foo - components with custom paths
// - html:div - tags punctuated by `:`
// - {div}, {"div"} - any rust expression
impl IdeTagHelper {
pub fn new() -> Self {
Self(Vec::new())
}
/// Save stmts for tag name.
/// Emit warning if tag is component.
pub fn save_tag_completion(&mut self, name: &NodeName) {
let tag_name = name.to_string();
if is_component_tag_name(&tag_name) {
proc_macro_error::emit_warning!(
name.span(),
"BUG: Component tag is used in regular tag completion."
);
}
for path in Self::completion_stmts(name) {
self.0.push(quote! {
let _ = #path;
});
}
}
/// Save stmts for open and close tags.
/// Emit warning if tag is component.
pub fn save_element_completion(&mut self, node: &NodeElement) {
self.save_tag_completion(node.name());
if let Some(close_tag) = node.close_tag.as_ref().map(|c| &c.name) {
self.save_tag_completion(close_tag)
}
}
/* This has been (temporarily?) removed.
* Its purpose was simply to add syntax highlighting and IDE hints for
* component closing tags in debug mode by associating the closing tag
* ident with the component function.
*
* Doing this in a way that correctly inferred types, however, required
* duplicating the entire component constructor.
*
* In view trees with many nested components, this led to a massive explosion
* in compile times.
*
* See https://github.com/leptos-rs/leptos/issues/1283
*
/// Add completion to the closing tag of the component.
///
/// In order to ensure that generics are passed through correctly in the
/// current builder pattern, this clones the whole component constructor,
/// but it will never be used.
///
/// ```no_build
/// if false {
/// close_tag(cx, unreachable!())
/// }
/// else {
/// open_tag(open_tag.props().slots().children().build())
/// }
/// ```
#[cfg(debug_assertions)]
pub fn add_component_completion(
cx: &Ident,
component: &mut TokenStream,
node: &NodeElement,
) {
// emit ide helper info
if let Some(close_tag) = node.close_tag.as_ref().map(|c| &c.name) {
*component = quote! {
{
let #close_tag = |cx| #component;
#close_tag(#cx)
}
}
}
}
*/
/// Returns `syn::Path`-like `TokenStream` to the fn in docs.
/// If tag name is `Component` returns `None`.
fn create_regular_tag_fn_path(name: &Ident) -> TokenStream {
let tag_name = name.to_string();
let namespace = if crate::view::is_svg_element(&tag_name) {
quote! { ::leptos::leptos_dom::svg }
} else if crate::view::is_math_ml_element(&tag_name) {
quote! { ::leptos::leptos_dom::math }
} else {
// todo: check is html, and emit_warning in case of custom tag
quote! { ::leptos::leptos_dom::html }
};
quote!( #namespace::#name)
}
/// Returns `syn::Path`-like `TokenStream` to the `custom` section in docs.
fn create_custom_tag_fn_path(span: Span) -> TokenStream {
let custom_ident = Ident::new("custom", span);
quote! {leptos::leptos_dom::html::#custom_ident::<leptos::leptos_dom::html::Custom>}
}
// Extract from NodeName completion idents.
// Custom tags (like foo-bar-baz) is mapped
// to vec!["custom", "custom",.. ] for each token in tag, even for "-".
// Only last ident from `Path` is used.
fn completion_stmts(name: &NodeName) -> Vec<TokenStream> {
match name {
NodeName::Block(_) => vec![],
NodeName::Punctuated(c) => c
.pairs()
.flat_map(|c| {
let mut idents =
vec![Self::create_custom_tag_fn_path(c.value().span())];
if let Some(p) = c.punct() {
idents.push(Self::create_custom_tag_fn_path(p.span()))
}
idents
})
.collect(),
NodeName::Path(e) => e
.path
.segments
.last()
.map(|p| &p.ident)
.map(Self::create_regular_tag_fn_path)
.into_iter()
.collect(),
}
}
}
impl IntoIterator for IdeTagHelper {
type Item = TokenStream;
type IntoIter = <Vec<TokenStream> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

View File

@@ -0,0 +1,548 @@
use crate::{attribute_value, Mode};
use convert_case::{Case::Snake, Casing};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{quote, quote_spanned};
use rstml::node::{KeyedAttribute, Node, NodeElement, NodeName};
use syn::{spanned::Spanned, Expr, Expr::Tuple, ExprLit, ExprPath, Lit};
pub mod client_builder;
pub mod client_template;
pub mod component_builder;
pub mod ide_helper;
pub mod server_template;
pub mod slot_helper;
#[cfg(test)]
mod tests;
pub(crate) use ide_helper::*;
pub(crate) fn render_view(
cx: &Ident,
nodes: &[Node],
mode: Mode,
global_class: Option<&TokenTree>,
call_site: Option<String>,
) -> TokenStream {
let empty = {
let span = Span::call_site();
quote_spanned! {
span => leptos::leptos_dom::Unit
}
};
if mode == Mode::Ssr {
match nodes.len() {
0 => empty,
1 => server_template::root_node_to_tokens_ssr(
cx,
&nodes[0],
global_class,
call_site,
),
_ => server_template::fragment_to_tokens_ssr(
cx,
Span::call_site(),
nodes,
global_class,
call_site,
),
}
} else {
match nodes.len() {
0 => empty,
1 => client_builder::node_to_tokens(
cx,
&nodes[0],
client_builder::TagType::Unknown,
None,
global_class,
call_site,
)
.unwrap_or_default(),
_ => client_builder::fragment_to_tokens(
cx,
Span::call_site(),
nodes,
true,
client_builder::TagType::Unknown,
None,
global_class,
call_site,
)
.unwrap_or(empty),
}
}
}
// Keep list alphabetized for binary search
const TYPED_EVENTS: [&str; 126] = [
"DOMContentLoaded",
"abort",
"afterprint",
"animationcancel",
"animationend",
"animationiteration",
"animationstart",
"auxclick",
"beforeinput",
"beforeprint",
"beforeunload",
"blur",
"canplay",
"canplaythrough",
"change",
"click",
"close",
"compositionend",
"compositionstart",
"compositionupdate",
"contextmenu",
"copy",
"cuechange",
"cut",
"dblclick",
"devicemotion",
"deviceorientation",
"drag",
"dragend",
"dragenter",
"dragleave",
"dragover",
"dragstart",
"drop",
"durationchange",
"emptied",
"ended",
"error",
"focus",
"focusin",
"focusout",
"formdata",
"fullscreenchange",
"fullscreenerror",
"gamepadconnected",
"gamepaddisconnected",
"gotpointercapture",
"hashchange",
"input",
"invalid",
"keydown",
"keypress",
"keyup",
"languagechange",
"load",
"loadeddata",
"loadedmetadata",
"loadstart",
"lostpointercapture",
"message",
"messageerror",
"mousedown",
"mouseenter",
"mouseleave",
"mousemove",
"mouseout",
"mouseover",
"mouseup",
"offline",
"online",
"orientationchange",
"pagehide",
"pageshow",
"paste",
"pause",
"play",
"playing",
"pointercancel",
"pointerdown",
"pointerenter",
"pointerleave",
"pointerlockchange",
"pointerlockerror",
"pointermove",
"pointerout",
"pointerover",
"pointerup",
"popstate",
"progress",
"ratechange",
"readystatechange",
"rejectionhandled",
"reset",
"resize",
"scroll",
"securitypolicyviolation",
"seeked",
"seeking",
"select",
"selectionchange",
"selectstart",
"slotchange",
"stalled",
"storage",
"submit",
"suspend",
"timeupdate",
"toggle",
"touchcancel",
"touchend",
"touchmove",
"touchstart",
"transitioncancel",
"transitionend",
"transitionrun",
"transitionstart",
"unhandledrejection",
"unload",
"visibilitychange",
"volumechange",
"waiting",
"webkitanimationend",
"webkitanimationiteration",
"webkitanimationstart",
"webkittransitionend",
"wheel",
];
const CUSTOM_EVENT: &str = "Custom";
pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool) {
let (name, is_force_undelegated) = parse_event(name);
let (event_type, is_custom) = TYPED_EVENTS
.binary_search(&name)
.map(|_| (name, false))
.unwrap_or((CUSTOM_EVENT, true));
let Ok(event_type) = event_type.parse::<TokenStream>() else {
abort!(event_type, "couldn't parse event name");
};
let event_type = if is_custom {
quote! { Custom::new(#name) }
} else {
event_type
};
(event_type, is_custom, is_force_undelegated)
}
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 {
expr_to_ident(expr)
} else {
None
}
}),
syn::Expr::Path(path) => Some(path),
_ => None,
}
}
fn convert_to_snake_case(name: String) -> String {
if !name.is_case(Snake) {
name.to_case(Snake)
} else {
name
}
}
fn is_custom_element(tag: &str) -> bool {
tag.contains('-')
}
fn is_self_closing(node: &NodeElement) -> bool {
// self-closing tags
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr",
]
.binary_search(&node.name().to_string().as_str())
.is_ok()
}
fn camel_case_tag_name(tag_name: &str) -> String {
let mut chars = tag_name.chars();
let first = chars.next();
let underscore = if tag_name == "option" { "_" } else { "" };
first
.map(|f| f.to_ascii_uppercase())
.into_iter()
.chain(chars)
.collect::<String>()
+ underscore
}
fn is_svg_element(tag: &str) -> bool {
// Keep list alphabetized for binary search
[
"animate",
"animateMotion",
"animateTransform",
"circle",
"clipPath",
"defs",
"desc",
"discard",
"ellipse",
"feBlend",
"feColorMatrix",
"feComponentTransfer",
"feComposite",
"feConvolveMatrix",
"feDiffuseLighting",
"feDisplacementMap",
"feDistantLight",
"feDropShadow",
"feFlood",
"feFuncA",
"feFuncB",
"feFuncG",
"feFuncR",
"feGaussianBlur",
"feImage",
"feMerge",
"feMergeNode",
"feMorphology",
"feOffset",
"fePointLight",
"feSpecularLighting",
"feSpotLight",
"feTile",
"feTurbulence",
"filter",
"foreignObject",
"g",
"hatch",
"hatchpath",
"image",
"line",
"linearGradient",
"marker",
"mask",
"metadata",
"mpath",
"path",
"pattern",
"polygon",
"polyline",
"radialGradient",
"rect",
"set",
"stop",
"svg",
"switch",
"symbol",
"text",
"textPath",
"tspan",
"use",
"use_",
"view",
]
.binary_search(&tag)
.is_ok()
}
fn is_math_ml_element(tag: &str) -> bool {
// Keep list alphabetized for binary search
[
"annotation",
"maction",
"math",
"menclose",
"merror",
"mfenced",
"mfrac",
"mi",
"mmultiscripts",
"mn",
"mo",
"mover",
"mpadded",
"mphantom",
"mprescripts",
"mroot",
"mrow",
"ms",
"mspace",
"msqrt",
"mstyle",
"msub",
"msubsup",
"msup",
"mtable",
"mtd",
"mtext",
"mtr",
"munder",
"munderover",
"semantics",
]
.binary_search(&tag)
.is_ok()
}
fn is_ambiguous_element(tag: &str) -> bool {
tag == "a" || tag == "script" || tag == "title"
}
fn parse_event(event_name: &str) -> (&str, bool) {
if let Some(event_name) = event_name.strip_suffix(":undelegated") {
(event_name, true)
} else {
(event_name, false)
}
}
fn fancy_class_name<'a>(
name: &str,
cx: &Ident,
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(Tuple(tuple)) = node.value() {
if tuple.elems.len() == 2 {
let span = node.key.span();
let class = quote_spanned! {
span => .class
};
let class_name = &tuple.elems[0];
let class_name = if let Expr::Lit(ExprLit {
lit: Lit::Str(s),
..
}) = class_name
{
s.value()
} else {
proc_macro_error::emit_error!(
class_name.span(),
"class name must be a string literal"
);
Default::default()
};
let value = &tuple.elems[1];
return Some((
quote! {
#class(#class_name, (#cx, #value))
},
class_name,
value,
));
} else {
proc_macro_error::emit_error!(
tuple.span(),
"class tuples must have two elements."
)
}
}
}
None
}
fn ident_from_tag_name(tag_name: &NodeName) -> Ident {
match tag_name {
NodeName::Path(path) => path
.path
.segments
.iter()
.last()
.map(|segment| segment.ident.clone())
.expect("element needs to have a name"),
NodeName::Block(_) => {
let span = tag_name.span();
proc_macro_error::emit_error!(
span,
"blocks not allowed in tag-name position"
);
Ident::new("", span)
}
_ => Ident::new(
&tag_name.to_string().replace(['-', ':'], "_"),
tag_name.span(),
),
}
}
fn fancy_style_name<'a>(
name: &str,
cx: &Ident,
node: &'a KeyedAttribute,
) -> Option<(TokenStream, String, &'a Expr)> {
// special case for complex dynamic style names:
if name == "style" {
if let Some(Tuple(tuple)) = node.value() {
if tuple.elems.len() == 2 {
let span = node.key.span();
let style = quote_spanned! {
span => .style
};
let style_name = &tuple.elems[0];
let style_name = if let Expr::Lit(ExprLit {
lit: Lit::Str(s),
..
}) = style_name
{
s.value()
} else {
proc_macro_error::emit_error!(
style_name.span(),
"style name must be a string literal"
);
Default::default()
};
let value = &tuple.elems[1];
return Some((
quote! {
#style(#style_name, (#cx, #value))
},
style_name,
value,
));
} else {
proc_macro_error::emit_error!(
tuple.span(),
"style tuples must have two elements."
)
}
}
}
None
}
pub(crate) fn event_from_attribute_node(
attr: &KeyedAttribute,
force_undelegated: bool,
) -> (TokenStream, &Expr) {
let event_name = attr
.key
.to_string()
.strip_prefix("on:")
.expect("expected `on:` directive")
.to_owned();
let handler = attribute_value(attr);
#[allow(unused_variables)]
let (name, name_undelegated) = parse_event(&event_name);
let event_type = TYPED_EVENTS
.binary_search(&name)
.map(|_| (name))
.unwrap_or(CUSTOM_EVENT);
let Ok(event_type) = event_type.parse::<TokenStream>() else {
abort!(attr.key, "couldn't parse event name");
};
let event_type = if force_undelegated || name_undelegated {
quote! { ::leptos::leptos_dom::ev::undelegated(::leptos::leptos_dom::ev::#event_type) }
} else {
quote! { ::leptos::leptos_dom::ev::#event_type }
};
(event_type, handler)
}

View File

@@ -0,0 +1,707 @@
use super::{
camel_case_tag_name,
component_builder::component_to_tokens,
fancy_class_name, fancy_style_name,
ide_helper::IdeTagHelper,
is_custom_element, is_math_ml_element, is_self_closing, is_svg_element,
parse_event_name,
slot_helper::{get_slot, slot_to_tokens},
};
use crate::attribute_value;
use leptos_hot_reload::parsing::{
block_to_primitive_expression, is_component_node, value_to_string,
};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::quote;
use rstml::node::{
KeyedAttribute, Node, NodeAttribute, NodeBlock, NodeElement,
};
use std::collections::HashMap;
use syn::spanned::Spanned;
pub(crate) enum SsrElementChunks {
String {
template: String,
holes: Vec<TokenStream>,
},
View(TokenStream),
}
pub(crate) fn root_node_to_tokens_ssr(
cx: &Ident,
node: &Node,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
match node {
Node::Fragment(fragment) => fragment_to_tokens_ssr(
cx,
Span::call_site(),
&fragment.children,
global_class,
view_marker,
),
Node::Comment(_) | Node::Doctype(_) => quote! {},
Node::Text(node) => {
quote! {
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) => {
quote! {
#node
}
}
Node::Element(node) => {
root_element_to_tokens_ssr(cx, node, global_class, view_marker)
.unwrap_or_default()
}
}
}
pub(crate) fn fragment_to_tokens_ssr(
cx: &Ident,
_span: Span,
nodes: &[Node],
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
let nodes = nodes.iter().map(|node| {
let node = root_node_to_tokens_ssr(cx, node, global_class, None);
quote! {
#node.into_view(#cx)
}
});
quote! {
{
leptos::Fragment::lazy(|| [
#(#nodes),*
].to_vec())
#view_marker
}
}
}
pub(crate) fn root_element_to_tokens_ssr(
cx: &Ident,
node: &NodeElement,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> Option<TokenStream> {
// TODO: simplify, this is checked twice, second time in `element_to_tokens_ssr` body
if is_component_node(node) {
if let Some(slot) = get_slot(node) {
slot_to_tokens(cx, node, slot, None, global_class);
None
} else {
Some(component_to_tokens(cx, node, global_class))
}
} else {
let mut stmts_for_ide = IdeTagHelper::new();
let mut exprs_for_compiler = Vec::<TokenStream>::new();
let mut template = String::new();
let mut holes = Vec::new();
let mut chunks = Vec::new();
element_to_tokens_ssr(
cx,
node,
None,
&mut template,
&mut holes,
&mut chunks,
&mut stmts_for_ide,
&mut exprs_for_compiler,
true,
global_class,
);
// push final chunk
if !template.is_empty() {
chunks.push(SsrElementChunks::String { template, holes })
}
let chunks = chunks.into_iter().map(|chunk| match chunk {
SsrElementChunks::String { template, holes } => {
if holes.is_empty() {
let template = template.replace("\\{", "{").replace("\\}", "}");
quote! {
leptos::leptos_dom::html::StringOrView::String(#template.into())
}
} else {
let template = template.replace("\\{", "{{").replace("\\}", "}}");
quote! {
leptos::leptos_dom::html::StringOrView::String(
format!(
#template,
#(#holes),*
)
.into()
)
}
}
}
SsrElementChunks::View(view) => {
quote! {
#[allow(unused_braces)]
{
let view = #view;
leptos::leptos_dom::html::StringOrView::View(std::rc::Rc::new(move || view.clone()))
}
}
},
});
let tag_name = node.name().to_string();
let is_custom_element = is_custom_element(&tag_name);
// Use any other span instead of node.name.span(), to avoid misunderstanding in IDE.
// We can use open_tag.span(), to provide similar (to name span) diagnostic
// in case of expansion error, but it will also highlight "<" token.
let typed_element_name = if is_custom_element {
Ident::new("Custom", Span::call_site())
} else {
let camel_cased = camel_case_tag_name(
tag_name
.trim_start_matches("svg::")
.trim_start_matches("math::")
.trim_end_matches('_'),
);
Ident::new(&camel_cased, Span::call_site())
};
let typed_element_name = if is_svg_element(&tag_name) {
quote! { svg::#typed_element_name }
} else if is_math_ml_element(&tag_name) {
quote! { math::#typed_element_name }
} else {
quote! { html::#typed_element_name }
};
let full_name = if is_custom_element {
quote! {
::leptos::leptos_dom::html::Custom::new(#tag_name)
}
} else {
quote! {
::leptos::leptos_dom::#typed_element_name::default()
}
};
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
let stmts_for_ide = stmts_for_ide.into_iter();
Some(quote! {
{
#(#stmts_for_ide)*
#(#exprs_for_compiler)*
::leptos::HtmlElement::from_chunks(#cx, #full_name, [#(#chunks),*])#view_marker
}
})
}
}
#[allow(clippy::too_many_arguments)]
fn element_to_tokens_ssr(
cx: &Ident,
node: &NodeElement,
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
template: &mut String,
holes: &mut Vec<TokenStream>,
chunks: &mut Vec<SsrElementChunks>,
stmts_for_ide: &mut IdeTagHelper,
exprs_for_compiler: &mut Vec<TokenStream>,
is_root: bool,
global_class: Option<&TokenTree>,
) {
if is_component_node(node) {
if let Some(slot) = get_slot(node) {
slot_to_tokens(cx, node, slot, parent_slots, global_class);
return;
}
let component = component_to_tokens(cx, node, global_class);
if !template.is_empty() {
chunks.push(SsrElementChunks::String {
template: std::mem::take(template),
holes: std::mem::take(holes),
})
}
chunks.push(SsrElementChunks::View(quote! {
{#component}.into_view(#cx)
}));
} else {
let tag_name = node.name().to_string();
let tag_name = tag_name
.trim_start_matches("svg::")
.trim_start_matches("math::")
.trim_end_matches('_');
let is_script_or_style = tag_name == "script" || tag_name == "style";
template.push('<');
template.push_str(tag_name);
#[cfg(debug_assertions)]
stmts_for_ide.save_element_completion(node);
let mut inner_html = None;
for attr in node.attributes() {
if let NodeAttribute::Attribute(attr) = attr {
inner_html = attribute_to_tokens_ssr(
cx,
attr,
template,
holes,
exprs_for_compiler,
global_class,
);
}
}
// insert hydration ID
let hydration_id = if is_root {
quote! { ::leptos::leptos_dom::HydrationCtx::peek() }
} else {
quote! { ::leptos::leptos_dom::HydrationCtx::id() }
};
match node
.attributes()
.iter()
.find(|node| matches!(node, NodeAttribute::Attribute(attr) if attr.key.to_string() == "id"))
{
Some(_) => {
template.push_str(" leptos-hk=\"_{}\"");
}
None => {
template.push_str(" id=\"_{}\"");
}
}
holes.push(hydration_id);
set_class_attribute_ssr(cx, node, template, holes, global_class);
set_style_attribute_ssr(cx, node, template, holes);
if is_self_closing(node) {
template.push_str("/>");
} else {
template.push('>');
if let Some(inner_html) = inner_html {
template.push_str("{}");
let value = inner_html;
holes.push(quote! {
(#value).into_attribute(#cx).as_nameless_value_string().unwrap_or_default()
})
} else {
for child in &node.children {
match child {
Node::Element(child) => {
element_to_tokens_ssr(
cx,
child,
None,
template,
holes,
chunks,
stmts_for_ide,
exprs_for_compiler,
false,
global_class,
);
}
Node::Text(text) => {
let value = text.value_string();
let value = if is_script_or_style {
value.into()
} else {
html_escape::encode_safe(&value)
};
template.push_str(
&value.replace('{', "\\{").replace('}', "\\}"),
);
}
Node::RawText(r) => {
let value = r.to_string_best();
let value = if is_script_or_style {
value.into()
} else {
html_escape::encode_safe(&value)
};
template.push_str(
&value.replace('{', "\\{").replace('}', "\\}"),
);
}
Node::Block(NodeBlock::ValidBlock(block)) => {
if let Some(value) =
block_to_primitive_expression(block)
.and_then(value_to_string)
{
template.push_str(&value);
} else {
if !template.is_empty() {
chunks.push(SsrElementChunks::String {
template: std::mem::take(template),
holes: std::mem::take(holes),
})
}
chunks.push(SsrElementChunks::View(quote! {
{#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."
),
Node::Comment(_) | Node::Doctype(_) => {}
}
}
}
template.push_str("</");
template.push_str(tag_name);
template.push('>');
}
}
}
// returns `inner_html`
fn attribute_to_tokens_ssr<'a>(
cx: &Ident,
attr: &'a KeyedAttribute,
template: &mut String,
holes: &mut Vec<TokenStream>,
exprs_for_compiler: &mut Vec<TokenStream>,
global_class: Option<&TokenTree>,
) -> 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(attr);
let (event_type, _, _) = parse_event_name(name);
exprs_for_compiler.push(quote! {
leptos::leptos_dom::helpers::ssr_event_listener(::leptos::ev::#event_type, #handler);
})
} else if name.strip_prefix("prop:").is_some()
|| name.strip_prefix("class:").is_some()
|| name.strip_prefix("style:").is_some()
{
// ignore props for SSR
// ignore classes and sdtyles: we'll handle these separately
if name.starts_with("prop:") {
let value = attr.value();
exprs_for_compiler.push(quote! {
#[allow(unused_braces)]
{ _ = #value; }
});
}
} else if name == "inner_html" {
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()
&& attr.value().and_then(value_to_string).is_none()
{
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 \
for more information and an example: https://github.com/leptos-rs/leptos/issues/773")
};
if name != "class" && name != "style" {
template.push(' ');
if let Some(value) = attr.value() {
if let Some(value) = value_to_string(value) {
template.push_str(&name);
template.push_str("=\"");
template.push_str(&html_escape::encode_quoted_attribute(
&value,
));
template.push('"');
} else {
template.push_str("{}");
holes.push(quote! {
&{#value}.into_attribute(#cx)
.as_nameless_value_string()
.map(|a| format!("{}=\"{}\"", #name, leptos::leptos_dom::ssr::escape_attr(&a)))
.unwrap_or_default()
})
}
} else {
template.push_str(&name);
}
}
};
None
}
fn set_class_attribute_ssr(
cx: &Ident,
node: &NodeElement,
template: &mut String,
holes: &mut Vec<TokenStream>,
global_class: Option<&TokenTree>,
) {
let (static_global_class, dyn_global_class) = match global_class {
Some(TokenTree::Literal(lit)) => {
let str = lit.to_string();
// A lit here can be a string, byte_string, char, byte_char, int or float.
// If it's a string we remove the quotes so folks can use them directly
// without needing braces. E.g. view!{cx, class="my-class", ... }
let str = if str.starts_with('"') && str.ends_with('"') {
str[1..str.len() - 1].to_string()
} else {
str
};
(str, None)
}
None => (String::new(), None),
Some(val) => (String::new(), Some(val)),
};
let static_class_attr = node
.attributes()
.iter()
.filter_map(|a| match a {
NodeAttribute::Attribute(attr)
if attr.key.to_string() == "class" =>
{
attr.value().and_then(value_to_string)
}
_ => None,
})
.chain(Some(static_global_class))
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
let dyn_class_attr = node
.attributes()
.iter()
.filter_map(|a| {
if let NodeAttribute::Attribute(a) = a {
if a.key.to_string() == "class" {
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()))
}
} else {
None
}
} else {
None
}
})
.collect::<Vec<_>>();
let class_attrs = node
.attributes()
.iter()
.filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
if name == "class" {
return if let Some((_, name, value)) =
fancy_class_name(&name, cx, node)
{
let span = node.key.span();
Some((span, name, value))
} else {
None
};
}
if name.starts_with("class:") || name.starts_with("class-") {
let name = if name.starts_with("class:") {
name.replacen("class:", "", 1)
} else if name.starts_with("class-") {
name.replacen("class-", "", 1)
} else {
name
};
let value = attribute_value(node);
let span = node.key.span();
Some((span, name, value))
} else {
None
}
} else {
None
}
})
.collect::<Vec<_>>();
if !static_class_attr.is_empty()
|| !dyn_class_attr.is_empty()
|| !class_attrs.is_empty()
|| dyn_global_class.is_some()
{
template.push_str(" class=\"");
template.push_str(&html_escape::encode_quoted_attribute(
&static_class_attr,
));
for (_span, value) in dyn_class_attr {
if let Some(value) = value {
template.push_str(" {}");
holes.push(quote! {
&(#cx, #value).into_attribute(#cx).as_nameless_value_string()
.map(|a| leptos::leptos_dom::ssr::escape_attr(&a).to_string())
.unwrap_or_default()
});
}
}
for (_span, name, value) in &class_attrs {
template.push_str(" {}");
holes.push(quote! {
(#cx, #value).into_class(#cx).as_value_string(#name)
});
}
if let Some(dyn_global_class) = dyn_global_class {
template.push_str(" {}");
holes.push(quote! { #dyn_global_class });
}
template.push('"');
}
}
fn set_style_attribute_ssr(
cx: &Ident,
node: &NodeElement,
template: &mut String,
holes: &mut Vec<TokenStream>,
) {
let static_style_attr = node
.attributes()
.iter()
.filter_map(|a| match a {
NodeAttribute::Attribute(attr)
if attr.key.to_string() == "style" =>
{
attr.value().and_then(value_to_string)
}
_ => None,
})
.next()
.map(|style| format!("{style};"));
let dyn_style_attr = node
.attributes()
.iter()
.filter_map(|a| {
if let NodeAttribute::Attribute(a) = a {
if a.key.to_string() == "style" {
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()))
}
} else {
None
}
} else {
None
}
})
.collect::<Vec<_>>();
let style_attrs = node
.attributes()
.iter()
.filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
let name = node.key.to_string();
if name == "style" {
return if let Some((_, name, value)) =
fancy_style_name(&name, cx, node)
{
let span = node.key.span();
Some((span, name, value))
} else {
None
};
}
if name.starts_with("style:") || name.starts_with("style-") {
let name = if name.starts_with("style:") {
name.replacen("style:", "", 1)
} else if name.starts_with("style-") {
name.replacen("style-", "", 1)
} else {
name
};
let value = attribute_value(node);
let span = node.key.span();
Some((span, name, value))
} else {
None
}
} else {
None
}
})
.collect::<Vec<_>>();
if static_style_attr.is_some()
|| !dyn_style_attr.is_empty()
|| !style_attrs.is_empty()
{
template.push_str(" style=\"");
template.push_str(&static_style_attr.unwrap_or_default());
for (_span, value) in dyn_style_attr {
if let Some(value) = value {
template.push_str(" {};");
holes.push(quote! {
&(#cx, #value).into_attribute(#cx).as_nameless_value_string()
.map(|a| leptos::leptos_dom::ssr::escape_attr(&a).to_string())
.unwrap_or_default()
});
}
}
for (_span, name, value) in &style_attrs {
template.push_str(" {}");
holes.push(quote! {
(#cx, #value).into_style(#cx).as_value_string(#name).unwrap_or_default()
});
}
template.push('"');
}
}

View File

@@ -0,0 +1,191 @@
use super::{
client_builder::{fragment_to_tokens, TagType},
convert_to_snake_case, ident_from_tag_name,
};
use proc_macro2::{Ident, TokenStream, TokenTree};
use quote::{format_ident, quote};
use rstml::node::{KeyedAttribute, NodeAttribute, NodeElement};
use std::collections::HashMap;
use syn::spanned::Spanned;
pub(crate) fn slot_to_tokens(
cx: &Ident,
node: &NodeElement,
slot: &KeyedAttribute,
parent_slots: Option<&mut HashMap<String, Vec<TokenStream>>>,
global_class: Option<&TokenTree>,
) {
let name = slot.key.to_string();
let name = name.trim();
let name = convert_to_snake_case(if name.starts_with("slot:") {
name.replacen("slot:", "", 1)
} else {
node.name().to_string()
});
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 NodeAttribute::Attribute(node) = node {
if is_slot(node) {
None
} else {
Some(node)
}
} else {
None
}
});
let props = attrs
.clone()
.filter(|attr| {
!attr.key.to_string().starts_with("bind:")
&& !attr.key.to_string().starts_with("clone:")
})
.map(|attr| {
let name = &attr.key;
let value = attr
.value()
.map(|v| {
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
quote! {
.#name(#[allow(unused_braces)] #value)
}
});
let items_to_bind = attrs
.clone()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("bind:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let items_to_clone = attrs
.clone()
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("clone:")
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
let mut slots = HashMap::new();
let children = if node.children.is_empty() {
quote! {}
} else {
cfg_if::cfg_if! {
if #[cfg(debug_assertions)] {
let marker = format!("<{component_name}/>-children");
let view_marker = quote! { .with_view_marker(#marker) };
} else {
let view_marker = quote! {};
}
}
let children = fragment_to_tokens(
cx,
span,
&node.children,
true,
TagType::Unknown,
Some(&mut slots),
global_class,
None,
);
if let Some(children) = children {
let bindables =
items_to_bind.iter().map(|ident| quote! { #ident, });
let clonables = items_to_clone
.iter()
.map(|ident| quote! { let #ident = #ident.clone(); });
if bindables.len() > 0 {
quote! {
.children({
#(#clonables)*
move |#cx, #(#bindables)*| #children #view_marker
})
}
} else {
quote! {
.children({
#(#clonables)*
Box::new(move |#cx| #children #view_marker)
})
}
}
} else {
quote! {}
}
};
let slots = slots.drain().map(|(slot, values)| {
let slot = Ident::new(&slot, span);
if values.len() > 1 {
quote! {
.#slot([
#(#values)*
].to_vec())
}
} else {
let value = &values[0];
quote! { .#slot(#value) }
}
});
let slot = quote! {
#component_name::builder()
#(#props)*
#(#slots)*
#children
.build()
.into(),
};
parent_slots
.entry(name)
.and_modify(|entry| entry.push(slot.clone()))
.or_insert(vec![slot]);
}
pub(crate) fn is_slot(node: &KeyedAttribute) -> bool {
let key = node.key.to_string();
let key = key.trim();
key == "slot" || key.starts_with("slot:")
}
pub(crate) 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 {
None
}
} else {
None
}
})
}

View File

@@ -0,0 +1,15 @@
---
source: leptos_macro/src/view/tests.rs
expression: pretty(result)
---
fn view() {
::leptos::component_view(
&SimpleCounter,
cx,
::leptos::component_props_builder(&SimpleCounter)
.initial_value(#[allow(unused_braces)] 0)
.step(#[allow(unused_braces)] 1)
.build(),
)
}

View File

@@ -0,0 +1,172 @@
---
source: leptos_macro/src/view/tests.rs
expression: result
---
TokenStream [
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: component_view,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '&',
spacing: Alone,
},
Ident {
sym: SimpleCounter,
span: bytes(11..24),
},
Punct {
char: ',',
spacing: Alone,
},
Ident {
sym: cx,
},
Punct {
char: ',',
spacing: Alone,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: component_props_builder,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '&',
spacing: Alone,
},
Ident {
sym: SimpleCounter,
span: bytes(11..24),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: initial_value,
span: bytes(37..50),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '#',
spacing: Alone,
},
Group {
delimiter: Bracket,
stream: TokenStream [
Ident {
sym: allow,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Ident {
sym: unused_braces,
},
],
},
],
},
Literal {
lit: 0,
span: bytes(51..52),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: step,
span: bytes(65..69),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '#',
spacing: Alone,
},
Group {
delimiter: Bracket,
stream: TokenStream [
Ident {
sym: allow,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Ident {
sym: unused_braces,
},
],
},
],
},
Literal {
lit: 1,
span: bytes(70..71),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: build,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
],
},
]

View File

@@ -0,0 +1,95 @@
---
source: leptos_macro/src/view/tests.rs
expression: pretty(result)
---
fn view() {
{
thread_local! {
static __TEMPLATE : ::leptos::web_sys::HtmlTemplateElement = { let document =
::leptos::document(); let el = document.create_element("template").unwrap();
el
.set_inner_html("<div><button>Clear</button><button>-1</button><span>Value: <!>!</span><button>+1</button></div>");
::leptos::wasm_bindgen::JsCast::unchecked_into(el) }
}
let _ = ::leptos::leptos_dom::html::div;
let _ = ::leptos::leptos_dom::html::div;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::span;
let _ = ::leptos::leptos_dom::html::span;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let root = __TEMPLATE
.with(|tpl| tpl.content().clone_node_with_deep(true))
.unwrap()
.first_child()
.unwrap();
let _el1 = "div";
let _el1 = ::leptos::wasm_bindgen::JsCast::unchecked_into::<
leptos::web_sys::Node,
>(root.clone());
let _el2 = "button";
let _el2 = _el1
.first_child()
.unwrap_or_else(|| panic!("error: {} => {}", "button", "firstChild"));
let _el3 = _el2
.first_child()
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
let _el4 = "button";
let _el4 = _el2
.next_sibling()
.unwrap_or_else(|| panic!("error : {} => {} ", "button", "nextSibling"));
let _el5 = _el4
.first_child()
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
let _el6 = "span";
let _el6 = _el4
.next_sibling()
.unwrap_or_else(|| panic!("error : {} => {} ", "span", "nextSibling"));
let _el7 = _el6
.first_child()
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
let _el8 = _el7
.next_sibling()
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "nextSibling"));
let _el9 = _el8
.next_sibling()
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "nextSibling"));
let _el10 = "button";
let _el10 = _el6
.next_sibling()
.unwrap_or_else(|| panic!("error : {} => {} ", "button", "nextSibling"));
let _el11 = _el10
.first_child()
.unwrap_or_else(|| panic!("error : {} => {} ", "{block}", "firstChild"));
::leptos::leptos_dom::add_event_helper(
::leptos::wasm_bindgen::JsCast::unchecked_ref(&_el2),
::leptos::leptos_dom::ev::click,
move |_| set_value(0),
);
::leptos::leptos_dom::add_event_helper(
::leptos::wasm_bindgen::JsCast::unchecked_ref(&_el4),
::leptos::leptos_dom::ev::click,
move |_| set_value.update(|value| *value -= step),
);
::leptos::leptos_dom::mount_child(
::leptos::leptos_dom::MountKind::Before(&_el8.clone()),
&{ { value } }.into_view(cx),
);
::leptos::leptos_dom::add_event_helper(
::leptos::wasm_bindgen::JsCast::unchecked_ref(&_el10),
::leptos::leptos_dom::ev::click,
move |_| set_value.update(|value| *value += step),
);
::leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
#[cfg(debug_assertions)]
name: "div".into(),
element: ::leptos::wasm_bindgen::JsCast::unchecked_into(root),
#[cfg(debug_assertions)]
view_marker: None,
})
}
}

View File

@@ -0,0 +1,15 @@
---
source: leptos_macro/src/view/tests.rs
expression: pretty(result)
---
fn view() {
::leptos::component_view(
&SimpleCounter,
cx,
::leptos::component_props_builder(&SimpleCounter)
.initial_value(#[allow(unused_braces)] 0)
.step(#[allow(unused_braces)] 1)
.build(),
)
}

View File

@@ -0,0 +1,172 @@
---
source: leptos_macro/src/view/tests.rs
expression: result
---
TokenStream [
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: component_view,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '&',
spacing: Alone,
},
Ident {
sym: SimpleCounter,
span: bytes(11..24),
},
Punct {
char: ',',
spacing: Alone,
},
Ident {
sym: cx,
},
Punct {
char: ',',
spacing: Alone,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: component_props_builder,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '&',
spacing: Alone,
},
Ident {
sym: SimpleCounter,
span: bytes(11..24),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: initial_value,
span: bytes(37..50),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '#',
spacing: Alone,
},
Group {
delimiter: Bracket,
stream: TokenStream [
Ident {
sym: allow,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Ident {
sym: unused_braces,
},
],
},
],
},
Literal {
lit: 0,
span: bytes(51..52),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: step,
span: bytes(65..69),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '#',
spacing: Alone,
},
Group {
delimiter: Bracket,
stream: TokenStream [
Ident {
sym: allow,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Ident {
sym: unused_braces,
},
],
},
],
},
Literal {
lit: 1,
span: bytes(70..71),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: build,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
],
},
]

View File

@@ -0,0 +1,54 @@
---
source: leptos_macro/src/view/tests.rs
expression: pretty(result)
---
fn view() {
{
let _ = ::leptos::leptos_dom::html::div;
::leptos::leptos_dom::html::div(cx)
.child((
cx,
{
let _ = ::leptos::leptos_dom::html::button;
::leptos::leptos_dom::html::button(cx)
.on(::leptos::ev::click, move |_| set_value(0))
.child("Clear")
},
))
.child((
cx,
{
let _ = ::leptos::leptos_dom::html::button;
::leptos::leptos_dom::html::button(cx)
.on(
::leptos::ev::click,
move |_| set_value.update(|value| *value -= step),
)
.child("-1")
},
))
.child((
cx,
{
let _ = ::leptos::leptos_dom::html::span;
::leptos::leptos_dom::html::span(cx)
.child("Value: ")
.child((cx, { value }))
.child("!")
},
))
.child((
cx,
{
let _ = ::leptos::leptos_dom::html::button;
::leptos::leptos_dom::html::button(cx)
.on(
::leptos::ev::click,
move |_| set_value.update(|value| *value += step),
)
.child("+1")
},
))
}
}

View File

@@ -0,0 +1,15 @@
---
source: leptos_macro/src/view/tests.rs
expression: pretty(result)
---
fn view() {
::leptos::component_view(
&SimpleCounter,
cx,
::leptos::component_props_builder(&SimpleCounter)
.initial_value(#[allow(unused_braces)] 0)
.step(#[allow(unused_braces)] 1)
.build(),
)
}

View File

@@ -0,0 +1,172 @@
---
source: leptos_macro/src/view/tests.rs
expression: result
---
TokenStream [
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: component_view,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '&',
spacing: Alone,
},
Ident {
sym: SimpleCounter,
span: bytes(11..24),
},
Punct {
char: ',',
spacing: Alone,
},
Ident {
sym: cx,
},
Punct {
char: ',',
spacing: Alone,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: leptos,
},
Punct {
char: ':',
spacing: Joint,
},
Punct {
char: ':',
spacing: Alone,
},
Ident {
sym: component_props_builder,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '&',
spacing: Alone,
},
Ident {
sym: SimpleCounter,
span: bytes(11..24),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: initial_value,
span: bytes(37..50),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '#',
spacing: Alone,
},
Group {
delimiter: Bracket,
stream: TokenStream [
Ident {
sym: allow,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Ident {
sym: unused_braces,
},
],
},
],
},
Literal {
lit: 0,
span: bytes(51..52),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: step,
span: bytes(65..69),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Punct {
char: '#',
spacing: Alone,
},
Group {
delimiter: Bracket,
stream: TokenStream [
Ident {
sym: allow,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Ident {
sym: unused_braces,
},
],
},
],
},
Literal {
lit: 1,
span: bytes(70..71),
},
],
},
Punct {
char: '.',
spacing: Alone,
},
Ident {
sym: build,
},
Group {
delimiter: Parenthesis,
stream: TokenStream [],
},
],
},
]

View File

@@ -0,0 +1,61 @@
---
source: leptos_macro/src/view/tests.rs
expression: pretty(result)
---
fn view() {
{
let _ = ::leptos::leptos_dom::html::div;
let _ = ::leptos::leptos_dom::html::div;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::span;
let _ = ::leptos::leptos_dom::html::span;
let _ = ::leptos::leptos_dom::html::button;
let _ = ::leptos::leptos_dom::html::button;
leptos::leptos_dom::helpers::ssr_event_listener(
::leptos::ev::click,
move |_| set_value(0),
);
leptos::leptos_dom::helpers::ssr_event_listener(
::leptos::ev::click,
move |_| set_value.update(|value| *value -= step),
);
leptos::leptos_dom::helpers::ssr_event_listener(
::leptos::ev::click,
move |_| set_value.update(|value| *value += step),
);
::leptos::HtmlElement::from_chunks(
cx,
::leptos::leptos_dom::html::Div::default(),
[
leptos::leptos_dom::html::StringOrView::String(
format!(
"<div id=\"_{}\"><button id=\"_{}\">Clear</button><button id=\"_{}\">-1</button><span id=\"_{}\">Value: ",
::leptos::leptos_dom::HydrationCtx::peek(),
::leptos::leptos_dom::HydrationCtx::id(),
::leptos::leptos_dom::HydrationCtx::id(),
::leptos::leptos_dom::HydrationCtx::id()
)
.into(),
),
#[allow(unused_braces)]
{
let view = { { value } }.into_view(cx);
leptos::leptos_dom::html::StringOrView::View(
std::rc::Rc::new(move || view.clone()),
)
},
leptos::leptos_dom::html::StringOrView::String(
format!(
"!</span><button id=\"_{}\">+1</button></div>",
::leptos::leptos_dom::HydrationCtx::id()
)
.into(),
),
],
)
}
}

View File

@@ -0,0 +1,119 @@
use proc_macro2::{Ident, Span, TokenStream};
use std::str::FromStr;
use syn::parse_quote;
fn pretty(input: TokenStream) -> String {
let type_item: syn::Item = parse_quote! {
fn view(){
#input
}
};
let file = syn::File {
shebang: None,
attrs: vec![],
items: vec![type_item],
};
prettyplease::unparse(&file)
}
macro_rules! assert_snapshot
{
(@assert text $result:ident) => {
insta::assert_snapshot!(pretty($result))
};
(@assert full $result:ident) => {
insta::assert_debug_snapshot!($result)
};
(client_template($assert:ident) => $input: expr) => {
{
let tokens = TokenStream::from_str($input).unwrap();
let cx = Ident::new("cx", Span::call_site());
let nodes = rstml::parse2(tokens).unwrap();
let result = crate::view::client_template::render_template(&cx, &nodes);
assert_snapshot!(@assert $assert result)
}
};
(client_builder($assert:ident) => $input: expr) => {
{
let tokens = TokenStream::from_str($input).unwrap();
let cx = Ident::new("cx", Span::call_site());
let nodes = rstml::parse2(tokens).unwrap();
let mode = crate::view::Mode::Client;
let global_class = None;
let call_site = None;
let result = crate::view::render_view(&cx, &nodes, mode, global_class, call_site);
assert_snapshot!(@assert $assert result)
}
};
(server_template($assert:ident) => $input: expr) => {
{
let tokens = TokenStream::from_str($input).unwrap();
let cx = Ident::new("cx", Span::call_site());
let nodes = rstml::parse2(tokens).unwrap();
let mode = crate::view::Mode::Ssr;
let global_class = None;
let call_site = None;
let result = crate::view::render_view(&cx, &nodes, mode, global_class, call_site);
assert_snapshot!(@assert $assert result)
}
}
}
macro_rules! for_all_modes {
(@ $module: ident, $type: ident => $(
$test_name:ident => $raw_str:expr
),*
) => {
mod $module {
use super::*;
$(
#[test]
fn $test_name() {
assert_snapshot!($type(text) => $raw_str)
}
)*
mod full_span {
use super::*;
$(
#[test]
fn $test_name() {
assert_snapshot!($type(full) => $raw_str)
}
)*
}
}
};
( $(
$tts:tt
)*
) => {
for_all_modes!{@ csr, client_builder => $($tts)*}
for_all_modes!{@ client_template, client_template => $($tts)*}
for_all_modes!{@ ssr, server_template => $($tts)*}
};
}
for_all_modes! {
test_simple_counter => r#"
<div>
<button on:click=move |_| set_value(0)>"Clear"</button>
<button on:click=move |_| set_value.update(|value| *value -= step)>"-1"</button>
<span>"Value: " {value} "!"</span>
<button on:click=move |_| set_value.update(|value| *value += step)>"+1"</button>
</div>
"#,
test_counter_component => r#"
<SimpleCounter
initial_value=0
step=1
/>
"#
}

View File

@@ -17,7 +17,6 @@ miniserde = { version = "0.1", optional = true }
rkyv = { version = "0.7.39", features = [
"validation",
"uuid",
"copy",
"strict",
], optional = true }
bytecheck = { version = "0.7", features = [
@@ -68,7 +67,7 @@ hydrate = [
"dep:web-sys",
]
ssr = ["dep:tokio"]
nightly = []
nightly = ["rkyv?/copy"]
serde = []
serde-lite = ["dep:serde-lite"]
miniserde = ["dep:miniserde"]

View File

@@ -4,7 +4,7 @@ version = { workspace = true }
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/gbj/leptos"
repository = "https://github.com/leptos-rs/leptos"
description = "RPC for the Leptos web framework."
readme = "../README.md"

View File

@@ -4,7 +4,7 @@ version = "0.4.8"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/gbj/leptos"
repository = "https://github.com/leptos-rs/leptos"
description = "Tools to set HTML metadata in the Leptos web framework."
[dependencies]

View File

@@ -51,7 +51,7 @@ where
let key = key.into();
let query_map = use_query_map(cx);
let navigate = use_navigate(cx);
let route = use_route(cx);
let location = use_location(cx);
let get = create_memo(cx, {
let key = key.clone();
@@ -72,7 +72,7 @@ where
}
}
let qs = new_query_map.to_query_string();
let path = route.path();
let path = location.pathname.get();
let new_url = format!("{path}{qs}");
let _ = navigate(&new_url, NavigateOptions::default());
});