Compare commits

...

25 Commits

Author SHA1 Message Date
Greg Johnston
5ad3164f6f fix: render empty dynamic text node in HTML as (closes #1382) 2023-08-07 15:56:58 -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
Greg Johnston
2f860b37bd v0.4.8 2023-08-02 19:25:32 -04:00
Greg Johnston
b86009b9d0 fix: remove erroneous logging 2023-08-02 19:16:32 -04:00
Greg Johnston
54733e1b34 v0.4.7 2023-08-02 17:03:38 -04:00
Greg Johnston
56f01888b7 Merge pull request #1486 from leptos-rs/export-all-helpers
fix: correctly export all DOM helpers
2023-08-02 17:02:19 -04:00
Greg Johnston
8320f16716 chore: fix new clippy warnings 2023-08-02 16:05:42 -04:00
Greg Johnston
0b16e5992d fix: correctly export all DOM helpers 2023-08-02 14:41:54 -04:00
Danik Vitek
248beb4a55 docs: typo in docs for ServerFnErrorErr (#1477) 2023-08-01 14:27:39 -04:00
martin frances
c9f608d030 docs: fix doclink to Error (#1469) 2023-08-01 13:24:13 -04:00
Greg Johnston
f837d3e6a2 fix: correctly escape HTML in DynChild text nodes (closes #1475) (#1478) 2023-08-01 13:22:24 -04:00
Greg Johnston
8847d5fc42 fix: compile-time regression for deeply-nested component trees (#1476) 2023-07-31 14:23:09 -04:00
Greg Johnston
7819a6fac0 fix: properly replace text nodes in DynChild (closes #1456) (#1472) 2023-07-30 22:37:53 -04:00
Marco Inacio
c199185808 docs: README.md to reflect new version (#1470) 2023-07-30 11:52:09 -04:00
martin frances
e0b5738606 chore: document the magic number in FILTER_SHOW_COMMENT. (#1468) 2023-07-29 16:53:10 -04:00
Sebastian Dobe
f3e3880a57 fix: AnimatedShow - possible panic on cleanup (#1464) 2023-07-29 06:33:49 -04:00
Greg Johnston
d44b90c16d feat: allow mut in component props and suppress "needless lifetime" warning (closes #1458) (#1459) 2023-07-29 06:32:06 -04:00
Joseph Cruz
cc32a3e863 perf(examples): speed up the test-info report (#1446) (#1447) 2023-07-27 20:40:26 -04:00
Greg Johnston
5740c9b76b feat: add MaybeProp type (#1443) 2023-07-27 18:18:25 -04:00
Greg Johnston
80fa6ad3eb docs: fix typo in 23_ssr_modes.md (#1445) 2023-07-26 16:33:21 -04:00
Greg Johnston
7bc1ad2b4f fix: incorrect opening node for <Each/> in debug mode (closes #1168) (#1436) 2023-07-26 10:43:46 -04:00
Joseph Cruz
82a2fe7cbe fix(examples): unable to parse makefile (#1440) (#1441) 2023-07-26 10:43:20 -04:00
50 changed files with 9931 additions and 2380 deletions

View File

@@ -26,22 +26,22 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.4.6"
version = "0.4.8"
[workspace.dependencies]
leptos = { path = "./leptos", version = "0.4.6" }
leptos_dom = { path = "./leptos_dom", version = "0.4.6" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.6" }
leptos_macro = { path = "./leptos_macro", version = "0.4.6" }
leptos_reactive = { path = "./leptos_reactive", version = "0.4.6" }
leptos_server = { path = "./leptos_server", version = "0.4.6" }
server_fn = { path = "./server_fn", version = "0.4.6" }
server_fn_macro = { path = "./server_fn_macro", version = "0.4.6" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.6" }
leptos_config = { path = "./leptos_config", version = "0.4.6" }
leptos_router = { path = "./router", version = "0.4.6" }
leptos_meta = { path = "./meta", version = "0.4.6" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.6" }
leptos = { path = "./leptos", version = "0.4.8" }
leptos_dom = { path = "./leptos_dom", version = "0.4.8" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.8" }
leptos_macro = { path = "./leptos_macro", version = "0.4.8" }
leptos_reactive = { path = "./leptos_reactive", version = "0.4.8" }
leptos_server = { path = "./leptos_server", version = "0.4.8" }
server_fn = { path = "./server_fn", version = "0.4.8" }
server_fn_macro = { path = "./server_fn_macro", version = "0.4.8" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.8" }
leptos_config = { path = "./leptos_config", version = "0.4.8" }
leptos_router = { path = "./router", version = "0.4.8" }
leptos_meta = { path = "./meta", version = "0.4.8" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.8" }
[profile.release]
codegen-units = 1

View File

@@ -88,8 +88,6 @@ targets = ["wasm32-unknown-unknown"]
The `nightly` feature enables the function call syntax for accessing and setting signals, as opposed to `.get()` and `.set()`. This leads to a consistent mental model in which accessing a reactive value of any kind (a signal, memo, or derived signal) is always represented as a function call. This is only possible with nightly Rust and the `nightly` feature.
> Note: The `nightly` feature is present on the main branch version right now, but not in 0.3.x. For 0.3.x, nightly is the default and `stable` has a special feature.
## `cargo-leptos`
[`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos) is a build tool that's designed to make it easy to build apps that run on both the client and the server, with seamless integration. The best way to get started with a real Leptos project right now is to use `cargo-leptos` and our starter templates for [Actix](https://github.com/leptos-rs/start) or [Axum](https://github.com/leptos-rs/start-axum).

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

@@ -84,7 +84,7 @@ Because it offers the best blend of performance characteristics, Leptos defaults
```rust
<Routes>
// Well load the home page with out-of-order streaming and <Suspense/>
<Route path="" view=HomePage
<Route path="" view=HomePage/>
// We'll load the posts with async rendering, so they can set
// the title and metadata *after* loading the data

View File

@@ -5,34 +5,34 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CARGO_BUILD_TEST_FLAGS = ""
CARGO_MAKE_WORKSPACE_EMULATION = true
CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
"animated_show",
"counter",
"counter_isomorphic",
"counters",
"counters_stable",
"counter_url_query,
"counter_without_macros",
"error_boundary",
"errors_axum",
"fetch",
"hackernews",
"hackernews_axum",
"js-framework-benchmark",
"leptos-tailwind-axum",
"login_with_token_csr_only",
"parent_child",
"router",
"session_auth_axum",
"slots",
"ssr_modes",
"ssr_modes_axum",
"tailwind",
"tailwind_csr_trunk",
"timer",
"todo_app_sqlite",
"todo_app_sqlite_axum",
"todo_app_sqlite_viz",
"todomvc",
"animated_show",
"counter",
"counter_isomorphic",
"counters",
"counters_stable",
"counter_url_query",
"counter_without_macros",
"error_boundary",
"errors_axum",
"fetch",
"hackernews",
"hackernews_axum",
"js-framework-benchmark",
"leptos-tailwind-axum",
"login_with_token_csr_only",
"parent_child",
"router",
"session_auth_axum",
"slots",
"ssr_modes",
"ssr_modes_axum",
"tailwind",
"tailwind_csr_trunk",
"timer",
"todo_app_sqlite",
"todo_app_sqlite_axum",
"todo_app_sqlite_viz",
"todomvc",
]
[tasks.gen-members]
@@ -48,9 +48,9 @@ jq -R -s -c 'split("\n")[:-1]')
echo "CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = $examples"
'''
[tasks.test-info]
[tasks.test-runner-report]
workspace = false
description = "report ci test runners for each example - Option [all]"
description = "report ci test runners for each example - OPTION: [all]"
script = '''
BOLD="\e[1m"
GREEN="\e[0;32m"
@@ -59,63 +59,53 @@ YELLOW="\e[0;33m"
RESET="\e[0m"
echo
echo "${YELLOW}CI test runners by example...${RESET}"
echo "${YELLOW}Test Runner Report${RESET}"
echo "${ITALIC}Pass the option \"all\" to show all the examples${RESET}"
echo
examples=$(ls |
grep -v README.md |
grep -v Makefile.toml |
grep -v cargo-make |
grep -v gtk |
sort -u |
awk '{print $0 ", "}')
makefile_paths=$(find . -name Makefile.toml -not -path '*/target/*' |
sed 's%./%%' |
sed 's%/Makefile.toml%%' |
grep -v Makefile.toml |
sort -u)
example_root_dir=$(pwd)
start_path=$(pwd)
for example_dir in $examples
do
clean_name=$(echo $example_dir | sed 's%,%%')
cd $clean_name
c_tests=$(grep -rl --fixed-strings "#[test]" | wc -l)
rs_tests=$(grep -rl --fixed-strings "#[rstest]" | wc -l)
w_configs=$(grep -rl "\/wasm-test.toml\"" | wc -l)
pw_configs=$(grep -rl "\/playwright-test.toml\"" | wc -l)
cl_configs=$(grep -rl "\/cargo-leptos-test.toml\"" | wc -l)
for path in $makefile_paths; do
cd $path
test_runner=
if [ $c_tests -gt 0 ]; then
test_count=$(grep -rl -E "#\[(test|rstest)\]" | wc -l)
if [ $test_count -gt 0 ]; then
test_runner="-C"
fi
if [ $rs_tests -gt 0 ]; then
test_runner=$test_runner"-R"
fi
while read -r line; do
case $line in
*"wasm-test.toml"*)
test_runner=$test_runner"-W"
;;
*"playwright-test.toml"*)
test_runner=$test_runner"-P"
;;
*"cargo-leptos-test.toml"*)
test_runner=$test_runner"-L"
;;
esac
done <"./Makefile.toml"
if [ $w_configs -gt 0 ]; then
test_runner=$test_runner"-W"
fi
if [ $pw_configs -gt 0 ]; then
test_runner=$test_runner"-P"
fi
if [ $cl_configs -gt 0 ]; then
test_runner=$test_runner"-L"
fi
if [ ! -z "$1" ]; then
# Show all examples
echo "$clean_name ${BOLD}${test_runner}${RESET}"
echo "$path ${BOLD}${test_runner}${RESET}"
elif [ ! -z $test_runner ]; then
# Filter out examples that do not run tests in `ci`
echo "$clean_name ${BOLD}${test_runner}${RESET}"
echo "$path ${BOLD}${test_runner}${RESET}"
fi
cd $example_root_dir
cd ${start_path}
done
echo
echo "${ITALIC}Test Runners: C = Cargo Test, L = Cargo Leptos Test, P = Playwright Test, R = RS Test, W = WASM Test${RESET}"
echo "${ITALIC}Runners: C = Cargo Test, L = Cargo Leptos Test, P = Playwright Test, W = WASM Test${RESET}"
echo
'''

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

@@ -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

@@ -98,7 +98,7 @@ pub fn AnimatedShow(
});
on_cleanup(cx, move || {
if let Some(h) = handle.get_value() {
if let Some(Some(h)) = handle.try_get_value() {
h.clear();
}
});

View File

@@ -156,18 +156,10 @@ pub mod ssr {
pub use leptos_dom::{ssr::*, ssr_in_order::*};
}
pub use leptos_dom::{
self, create_node_ref, debug_warn, document, error, ev,
helpers::{
event_target, event_target_checked, event_target_value,
request_animation_frame, request_animation_frame_with_handle,
request_idle_callback, request_idle_callback_with_handle, set_interval,
set_interval_with_handle, set_timeout, set_timeout_with_handle,
window_event_listener, window_event_listener_untyped,
},
html, log, math, mount_to, mount_to_body, nonce, svg, warn, window,
Attribute, Class, CollectView, Errors, Fragment, HtmlElement,
IntoAttribute, IntoClass, IntoProperty, IntoStyle, IntoView, NodeRef,
Property, View,
self, create_node_ref, debug_warn, document, error, ev, helpers::*, html,
log, math, mount_to, mount_to_body, nonce, svg, warn, window, Attribute,
Class, CollectView, Errors, Fragment, HtmlElement, IntoAttribute,
IntoClass, IntoProperty, IntoStyle, IntoView, NodeRef, Property, View,
};
/// Types to make it easier to handle errors in your application.

View File

@@ -72,9 +72,6 @@ where
let current_id = HydrationCtx::next_component();
let child = DynChild::new({
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
let current_id = current_id;
let children = Rc::new(orig_children(cx).into_view(cx));
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
let orig_children = Rc::clone(&orig_children);

View File

@@ -222,16 +222,31 @@ where
// node
let ret = if let Some(prev_t) = prev_t {
// Here, our child is also a text node
if let Some(new_t) = new_child.get_text() {
// nb: the match/ownership gymnastics here
// are so that, if we can reuse the text node,
// we can take ownership of new_t so we don't clone
// the contents, which in O(n) on the length of the text
if matches!(new_child, View::Text(_)) {
if !was_child_moved && child != new_child {
let mut new_t = match new_child {
View::Text(t) => t,
_ => unreachable!(),
};
prev_t
.unchecked_ref::<web_sys::Text>()
.set_data(&new_t.content);
// replace new_t's text node with the prev node
// see discussion: https://github.com/leptos-rs/leptos/pull/1472
new_t.node = prev_t.clone();
let new_child = View::Text(new_t);
**child_borrow = Some(new_child);
(Some(prev_t), disposer)
} else {
let new_t = new_child.as_text().unwrap();
mount_child(
MountKind::Before(&closing),
&new_child,

View File

@@ -377,6 +377,9 @@ where
let component = EachRepr::default();
#[cfg(all(debug_assertions, target_arch = "wasm32", feature = "web"))]
let opening = component.opening.node.clone().unchecked_into();
#[cfg(all(target_arch = "wasm32", feature = "web"))]
let (children, closing) =
(component.children.clone(), component.closing.node.clone());
@@ -387,7 +390,11 @@ where
move |prev_hash_run: Option<HashRun<FxIndexSet<K>>>| {
let mut children_borrow = children.borrow_mut();
#[cfg(all(target_arch = "wasm32", feature = "web"))]
#[cfg(all(
not(debug_assertions),
target_arch = "wasm32",
feature = "web"
))]
let opening = if let Some(Some(child)) = children_borrow.get(0)
{
// correctly remove opening <!--<EachItem/>-->

View File

@@ -1073,7 +1073,6 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
let id = *element.hydration_id();
let mut element = Element::new(element);
let children = children;
if attrs.iter_mut().any(|(name, _)| name == "id") {
attrs.push(("leptos-hk".into(), format!("_{id}").into()));

View File

@@ -6,6 +6,9 @@ mod hydration {
use std::{cell::RefCell, collections::HashMap};
use wasm_bindgen::JsCast;
/// See ["createTreeWalker"](https://developer.mozilla.org/en-US/docs/Web/API/Document/createTreeWalker)
const FILTER_SHOW_COMMENT: u32 = 0b10000000;
// We can tell if we start in hydration mode by checking to see if the
// id "_0-1" is present in the DOM. If it is, we know we are hydrating from
// the server, if not, we are starting off in CSR
@@ -14,7 +17,7 @@ mod hydration {
let document = crate::document();
let body = document.body().unwrap();
let walker = document
.create_tree_walker_with_what_to_show(&body, 128)
.create_tree_walker_with_what_to_show(&body, FILTER_SHOW_COMMENT)
.unwrap();
let mut map = HashMap::new();
while let Ok(Some(node)) = walker.next_node() {
@@ -34,7 +37,7 @@ mod hydration {
let document = crate::document();
let body = document.body().unwrap();
let walker = document
.create_tree_walker_with_what_to_show(&body, 128)
.create_tree_walker_with_what_to_show(&body, FILTER_SHOW_COMMENT)
.unwrap();
let mut map = HashMap::new();
while let Ok(Some(node)) = walker.next_node() {

View File

@@ -37,7 +37,7 @@ pub use hydration::{HydrationCtx, HydrationKey};
use leptos_reactive::Scope;
#[cfg(not(feature = "nightly"))]
use leptos_reactive::{
MaybeSignal, Memo, ReadSignal, RwSignal, Signal, SignalGet,
MaybeProp, MaybeSignal, Memo, ReadSignal, RwSignal, Signal, SignalGet,
};
pub use logging::*;
pub use macro_helpers::*;
@@ -211,6 +211,20 @@ where
}
}
#[cfg(not(feature = "nightly"))]
impl<T> IntoView for MaybeProp<T>
where
T: IntoView + Clone,
{
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(level = "trace", name = "MaybeSignal<T>", skip_all)
)]
fn into_view(self, cx: Scope) -> View {
DynChild::new(move || self.get()).into_view(cx)
}
}
/// Collects an iterator or collection into a [`View`].
pub trait CollectView {
/// Collects an iterator or collection into a [`View`].
@@ -272,8 +286,12 @@ cfg_if! {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use fmt::Write;
let attrs =
self.attrs.iter().map(|(n, v)| format!(" {n}=\"{v}\"")).collect::<String>();
let attrs = self.attrs.iter().fold(String::new(), |mut output, (n, v)| {
// can safely ignore output
// see https://rust-lang.github.io/rust-clippy/master/index.html#/format_collect
let _ = write!(output, " {n}=\"{v}\"");
output
});
if self.is_void {
write!(f, "<{}{attrs} />", self.name)

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,18 +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!("<!>{}", 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 {
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(
@@ -710,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,34 +416,50 @@ 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(
format!(
"<!>{}",
content
html_escape::encode_safe(
&content
)
)
.into(),
)
} else {
StreamChunk::Sync(content)
StreamChunk::Sync(html_escape::encode_safe(
&content
).to_string().into())
},
);
} else {

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

@@ -28,7 +28,7 @@ pub fn TestComponent(
/// # use example::TestComponent;
/// <TestComponent key="hello"/>
#[prop(optional)]
another:usize,
another: usize,
/// rust unclosed
/// ```view
/// use example::TestComponent;
@@ -38,3 +38,22 @@ pub fn TestComponent(
_ = (key, another, and_another);
}
#[component]
fn TestMutCallback<'a, F>(
cx: Scope,
mut callback: F,
value: &'a str,
) -> impl IntoView
where
F: FnMut(u32) + 'static,
{
let value = value.to_owned();
view! { cx,
<button on:click=move |_| {
callback(5);
}>
{value}
</button>
<TestComponent key="test"/>
}
}

View File

@@ -284,6 +284,8 @@ impl ToTokens for Model {
) #ret #(+ #lifetimes)*
#where_clause
{
// allowed for lifetimes that are needed for props struct
#[allow(clippy::needless_lifetimes)]
#body
#destructure_props
@@ -589,12 +591,14 @@ fn prop_builder_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
quote!()
};
let PatIdent { ident, by_ref, .. } = &name;
quote! {
#docs
#builder_docs
#builder_attrs
#allow_missing_docs
#vis #name: #ty,
#vis #by_ref #ident: #ty,
}
})
.collect()
@@ -604,7 +608,12 @@ fn prop_names(props: &[Prop]) -> TokenStream {
props
.iter()
.filter(|Prop { ty, .. }| !is_valid_scope_type(ty))
.map(|Prop { name, .. }| quote! { #name, })
.map(|Prop { name, .. }| {
// fields like mutability are removed because unneeded
// in the contexts in which this is used
let ident = &name.ident;
quote! { #ident, }
})
.collect()
}

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

@@ -370,7 +370,7 @@ fn push_cleanup(cx: Scope, cleanup_fn: Box<dyn FnOnce()>) {
let cleanups = cleanups
.entry(cx.id)
.expect("trying to clean up a Scope that has already been disposed")
.or_insert_with(Default::default);
.or_default();
cleanups.push(cleanup_fn);
});
}

View File

@@ -556,6 +556,15 @@ impl<T: Default> Default for MaybeSignal<T> {
/// # });
/// ```
impl<T: Clone> SignalGet<T> for MaybeSignal<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::get()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn get(&self) -> T {
match self {
Self::Static(t) => t.clone(),
@@ -563,6 +572,15 @@ impl<T: Clone> SignalGet<T> for MaybeSignal<T> {
}
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::try_get()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn try_get(&self) -> Option<T> {
match self {
Self::Static(t) => Some(t.clone()),
@@ -640,6 +658,15 @@ impl<T> SignalWith<T> for MaybeSignal<T> {
}
impl<T> SignalWithUntracked<T> for MaybeSignal<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::with_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> O {
match self {
Self::Static(t) => f(t),
@@ -647,6 +674,15 @@ impl<T> SignalWithUntracked<T> for MaybeSignal<T> {
}
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::try_with_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn try_with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> Option<O> {
match self {
Self::Static(t) => Some(f(t)),
@@ -656,6 +692,15 @@ impl<T> SignalWithUntracked<T> for MaybeSignal<T> {
}
impl<T: Clone> SignalGetUntracked<T> for MaybeSignal<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::get_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn get_untracked(&self) -> T {
match self {
Self::Static(t) => t.clone(),
@@ -663,6 +708,15 @@ impl<T: Clone> SignalGetUntracked<T> for MaybeSignal<T> {
}
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::try_get_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn try_get_untracked(&self) -> Option<T> {
match self {
Self::Static(t) => Some(t.clone()),
@@ -672,6 +726,15 @@ impl<T: Clone> SignalGetUntracked<T> for MaybeSignal<T> {
}
impl<T: Clone> SignalStream<T> for MaybeSignal<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeSignal::to_stream()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn to_stream(
&self,
cx: Scope,
@@ -764,4 +827,400 @@ impl From<&str> for MaybeSignal<String> {
}
}
/// A wrapping type for an optional component prop, which can either be a signal or a
/// non-reactive value, and which may or may not have a value. In other words, this is
/// an `Option<MaybeSignal<Option<T>>>` that automatically flattens its getters.
///
/// This creates an extremely flexible type for component libraries, etc.
///
/// ## Core Trait Implementations
/// - [`.get()`](#impl-SignalGet<T>-for-MaybeProp<T>) (or calling the signal as a function) clones the current
/// value of the signal. If you call it within an effect, it will cause that effect
/// to subscribe to the signal, and to re-run whenever the value of the signal changes.
/// - [`.get_untracked()`](#impl-SignalGetUntracked<T>-for-MaybeProp<T>) clones the value of the signal
/// without reactively tracking it.
/// - [`.with()`](#impl-SignalWith<T>-for-MaybeProp<T>) allows you to reactively access the signals value without
/// cloning by applying a callback function.
/// - [`.with_untracked()`](#impl-SignalWithUntracked<T>-for-MaybeProp<T>) allows you to access the signals
/// value without reactively tracking it.
/// - [`.to_stream()`](#impl-SignalStream<T>-for-MaybeProp<T>) converts the signal to an `async` stream of values.
///
/// ## Examples
/// ```rust
/// # use leptos_reactive::*;
/// # create_scope(create_runtime(), |cx| {
/// let (count, set_count) = create_signal(cx, Some(2));
/// let double = |n| n * 2;
/// let double_count = MaybeProp::derive(cx, move || count.get().map(double));
/// let memoized_double_count =
/// create_memo(cx, move |_| count.get().map(double));
/// let static_value = 5;
///
/// // this function takes either a reactive or non-reactive value
/// fn above_3(arg: &MaybeProp<i32>) -> bool {
/// // ✅ calling the signal clones and returns the value
/// // it is a shorthand for arg.get()q
/// arg.get().map(|arg| arg > 3).unwrap_or(false)
/// }
///
/// assert_eq!(above_3(&None::<i32>.into()), false);
/// assert_eq!(above_3(&static_value.into()), true);
/// assert_eq!(above_3(&count.into()), false);
/// assert_eq!(above_3(&double_count), true);
/// assert_eq!(above_3(&memoized_double_count.into()), true);
/// # });
/// ```
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MaybeProp<T: 'static>(Option<MaybeSignal<Option<T>>>);
impl<T: Copy> Copy for MaybeProp<T> {}
impl<T> Default for MaybeProp<T> {
fn default() -> Self {
Self(None)
}
}
/// # Examples
///
/// ```
/// # use leptos_reactive::*;
/// # create_scope(create_runtime(), |cx| {
/// let (count, set_count) = create_signal(cx, Some(2));
/// let double = |n| n * 2;
/// let double_count = MaybeProp::derive(cx, move || count.get().map(double));
/// let memoized_double_count =
/// create_memo(cx, move |_| count.get().map(double));
/// let static_value = 5;
///
/// // this function takes either a reactive or non-reactive value
/// fn above_3(arg: &MaybeProp<i32>) -> bool {
/// // ✅ calling the signal clones and returns the value
/// // it is a shorthand for arg.get()q
/// arg.get().map(|arg| arg > 3).unwrap_or(false)
/// }
///
/// assert_eq!(above_3(&None::<i32>.into()), false);
/// assert_eq!(above_3(&static_value.into()), true);
/// assert_eq!(above_3(&count.into()), false);
/// assert_eq!(above_3(&double_count), true);
/// assert_eq!(above_3(&memoized_double_count.into()), true);
/// # });
/// ```
impl<T: Clone> SignalGet<Option<T>> for MaybeProp<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::get()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn get(&self) -> Option<T> {
self.0.as_ref().and_then(|s| s.get())
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::try_get()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn try_get(&self) -> Option<Option<T>> {
self.0.as_ref().and_then(|s| s.try_get())
}
}
/// # Examples
///
/// ```
/// # use leptos_reactive::*;
/// # create_scope(create_runtime(), |cx| {
/// let (name, set_name) = create_signal(cx, Some("Alice".to_string()));
/// let name_upper = MaybeProp::derive(cx, move || {
/// name.with(|n| n.as_ref().map(|n| n.to_uppercase()))
/// });
/// let memoized_lower = create_memo(cx, move |_| {
/// name.with(|n| n.as_ref().map(|n| n.to_lowercase()))
/// });
/// let static_value: MaybeProp<String> = "Bob".to_string().into();
///
/// // this function takes any kind of wrapped signal
/// fn current_len_inefficient(arg: &MaybeProp<String>) -> usize {
/// // ❌ unnecessarily clones the string
/// arg.get().map(|n| n.len()).unwrap_or(0)
/// }
///
/// fn current_len(arg: &MaybeProp<String>) -> usize {
/// // ✅ gets the length without cloning the `String`
/// arg.with(|value| value.len()).unwrap_or(0)
/// }
///
/// assert_eq!(current_len(&None::<String>.into()), 0);
/// assert_eq!(current_len(&name_upper), 5);
/// assert_eq!(current_len(&memoized_lower.into()), 5);
/// assert_eq!(current_len(&static_value), 3);
///
/// assert_eq!(name.get(), Some("Alice".to_string()));
/// assert_eq!(name_upper.get(), Some("ALICE".to_string()));
/// assert_eq!(memoized_lower.get(), Some("alice".to_string()));
/// assert_eq!(static_value.get(), Some("Bob".to_string()));
/// # });
/// ```
impl<T> MaybeProp<T> {
/// Applies a function to the current value, returning the result.
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::with()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
pub fn with<O>(&self, f: impl FnOnce(&T) -> O) -> Option<O> {
self.0
.as_ref()
.and_then(|inner| inner.with(|value| value.as_ref().map(f)))
}
/// Applies a function to the current value, returning the result. Returns `None`
/// if the value has already been disposed.
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::try_with()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
pub fn try_with<O>(&self, f: impl FnOnce(&T) -> O) -> Option<O> {
self.0
.as_ref()
.and_then(|inner| inner.try_with(|value| value.as_ref().map(f)))
.flatten()
}
/// Applies a function to the current value, returning the result, without
/// causing the current reactive scope to track changes.
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::with_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
pub fn with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> Option<O> {
self.0.as_ref().and_then(|inner| {
inner.with_untracked(|value| value.as_ref().map(f))
})
}
/// Applies a function to the current value, returning the result, without
/// causing the current reactive scope to track changes. Returns `None` if
/// the value has already been disposed.
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::try_with_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
pub fn try_with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> Option<O> {
self.0
.as_ref()
.and_then(|inner| {
inner.try_with_untracked(|value| value.as_ref().map(f))
})
.flatten()
}
}
impl<T: Clone> SignalGetUntracked<Option<T>> for MaybeProp<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::get_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn get_untracked(&self) -> Option<T> {
self.0.as_ref().and_then(|inner| inner.get_untracked())
}
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::try_get_untracked()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn try_get_untracked(&self) -> Option<Option<T>> {
self.0.as_ref().and_then(|inner| inner.try_get_untracked())
}
}
impl<T: Clone> SignalStream<Option<T>> for MaybeProp<T> {
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::to_stream()",
skip_all,
fields(ty = %std::any::type_name::<T>())
)
)]
fn to_stream(
&self,
cx: Scope,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = Option<T>>>> {
match &self.0 {
None => Box::pin(futures::stream::once(async move { None })),
Some(MaybeSignal::Static(t)) => {
let t = t.clone();
let stream = futures::stream::once(async move { t });
Box::pin(stream)
}
Some(MaybeSignal::Dynamic(s)) => s.to_stream(cx),
}
}
}
impl<T> MaybeProp<T>
where
T: 'static,
{
/// Wraps a derived signal, i.e., any computation that accesses one or more
/// reactive signals.
/// ```rust
/// # use leptos_reactive::*;
/// # create_scope(create_runtime(), |cx| {
/// let (count, set_count) = create_signal(cx, Some(2));
/// let double_count = Signal::derive(cx, move || count.get().map(|n| n * 2));
///
/// // this function takes any kind of wrapped signal
/// fn above_3(arg: &MaybeProp<i32>) -> bool {
/// arg.get().unwrap_or(0) > 3
/// }
///
/// assert_eq!(above_3(&count.into()), false);
/// assert_eq!(above_3(&double_count.into()), true);
/// assert_eq!(above_3(&2.into()), false);
/// # });
/// ```
#[cfg_attr(
any(debug_assertions, feature = "ssr"),
instrument(
level = "trace",
name = "MaybeProp::derive()",
skip_all,
fields(
cx = ?cx.id,
ty = %std::any::type_name::<T>()
)
)
)]
pub fn derive(
cx: Scope,
derived_signal: impl Fn() -> Option<T> + 'static,
) -> Self {
Self(Some(MaybeSignal::derive(cx, derived_signal)))
}
}
impl<T> From<T> for MaybeProp<T> {
fn from(value: T) -> Self {
Self(Some(MaybeSignal::from(Some(value))))
}
}
impl<T> From<Option<T>> for MaybeProp<T> {
fn from(value: Option<T>) -> Self {
Self(Some(MaybeSignal::from(value)))
}
}
impl<T> From<MaybeSignal<Option<T>>> for MaybeProp<T> {
fn from(value: MaybeSignal<Option<T>>) -> Self {
Self(Some(value))
}
}
impl<T> From<Option<MaybeSignal<Option<T>>>> for MaybeProp<T> {
fn from(value: Option<MaybeSignal<Option<T>>>) -> Self {
Self(value)
}
}
impl<T> From<ReadSignal<Option<T>>> for MaybeProp<T> {
fn from(value: ReadSignal<Option<T>>) -> Self {
Self(Some(value.into()))
}
}
impl<T> From<RwSignal<Option<T>>> for MaybeProp<T> {
fn from(value: RwSignal<Option<T>>) -> Self {
Self(Some(value.into()))
}
}
impl<T> From<Memo<Option<T>>> for MaybeProp<T> {
fn from(value: Memo<Option<T>>) -> Self {
Self(Some(value.into()))
}
}
impl<T> From<Signal<Option<T>>> for MaybeProp<T> {
fn from(value: Signal<Option<T>>) -> Self {
Self(Some(value.into()))
}
}
impl From<&str> for MaybeProp<String> {
fn from(value: &str) -> Self {
Self(Some(MaybeSignal::from(Some(value.to_string()))))
}
}
impl_get_fn_traits![Signal, MaybeSignal];
#[cfg(feature = "nightly")]
impl<T: Clone> FnOnce<()> for MaybeProp<T> {
type Output = Option<T>;
#[inline(always)]
extern "rust-call" fn call_once(self, _args: ()) -> Self::Output {
self.get()
}
}
#[cfg(feature = "nightly")]
impl<T: Clone> FnMut<()> for MaybeProp<T> {
#[inline(always)]
extern "rust-call" fn call_mut(&mut self, _args: ()) -> Self::Output {
self.get()
}
}
#[cfg(feature = "nightly")]
impl<T: Clone> Fn<()> for MaybeProp<T> {
#[inline(always)]
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
self.get()
}
}

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

@@ -1,10 +1,10 @@
[package]
name = "leptos_meta"
version = "0.4.6"
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

@@ -1,6 +1,6 @@
[package]
name = "leptos_router"
version = "0.4.6"
version = "0.4.8"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"

View File

@@ -5,7 +5,7 @@ use thiserror::Error;
/// This is a result type into which any error can be converted,
/// and which can be used directly in your `view`.
///
/// All errors will be stored as [`Error`].
/// All errors will be stored as [`struct@Error`].
pub type Result<T, E = Error> = core::result::Result<T, E>;
/// A generic wrapper for any error.
@@ -110,7 +110,7 @@ where
/// Type for errors that can occur when using server functions.
///
/// Unlike [`ServerFnErrorErr`], this implements [`std::error::Error`]. This means
/// Unlike [`ServerFnError`], this implements [`std::error::Error`]. This means
/// it can be used in situations in which the `Error` trait is required, but its
/// not possible to create a blanket implementation that converts other errors into
/// this type.