Compare commits

...

18 Commits

Author SHA1 Message Date
Greg Johnston
e78ce7e6b9 feat: create_blocking_resource (#752) 2023-04-01 11:25:00 -04:00
Greg Johnston
a3327f8841 fix: SVG <title> tag (#783) 2023-04-01 11:24:32 -04:00
Greg Johnston
f727dd773b v0.2.5 (#782) 2023-04-01 11:23:42 -04:00
Greg Johnston
952646f066 Merge pull request #780 from leptos-rs/warn-on-routes-issues
docs: warn if you put something invalid inside `<Routes/>`
2023-03-31 17:13:02 -04:00
Greg Johnston
1e037ecb60 chore: clippy and docs warnings (#779) 2023-03-31 17:12:42 -04:00
Greg Johnston
c9f75d82d6 docs: warn if you add something that's not a <Route/> inside <Routes/> 2023-03-31 16:39:06 -04:00
Greg Johnston
de3849c20c example: show how to refactor routes into another component 2023-03-31 16:38:49 -04:00
Christian Rausch
c391c2e938 feat: arbitrary attributes to <Html/> and <Body/> meta tags (#726) 2023-03-31 16:30:10 -04:00
luoxiaozero
1cde4b1f8a docs: fixed parentheses and formatting issues (#775) 2023-03-31 15:48:29 -04:00
Greg Johnston
42360d109b change: insert <head> metadata tags at the beginning of the head, not the end (#731) 2023-03-31 14:51:27 -04:00
Kaszanas
7aa4d9e6db feat: Added `<ProtectedRoute/> component to route file (#741) 2023-03-31 14:50:46 -04:00
Kaszanas
9ed3390b81 examples: updated proxy settings in login_with_token_csr_only (#771)
When testing this example on Windows OS the initial value of `0.0.0.0:3000` for the IP did not work.
2023-03-31 14:44:06 -04:00
Greg Johnston
1ff56f7bfd fix: stop memoizing properties in a way that breaks prop:value (closes #768) (#772) 2023-03-30 19:44:38 -04:00
Greg Johnston
16917997cd fix: prevent forms from entering infinite loops (closes issue #760) (#762) 2023-03-30 16:28:49 -04:00
Greg Johnston
f42568d262 fix: <Redirect/> between nested routes at same level (#767) 2023-03-30 16:28:32 -04:00
Houski
97bbdf561a feat: added the id attribute to the Leptos router <A/> tag (#770) 2023-03-30 16:28:08 -04:00
Greg Johnston
f4043cbd9f fix: escape </script> and other HTML tags in serialized resources (#763) 2023-03-29 13:51:48 -04:00
Lukas Potthast
e9ff26abb4 feat: allow component declaration without use leptos::Scope in scope (#748) 2023-03-29 07:59:08 -04:00
37 changed files with 979 additions and 358 deletions

View File

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

View File

@@ -122,6 +122,7 @@ fn App(cx: Scope) -> impl IntoView {
provide_context(cx, state);
// ...
}
```
Then child components can access “slices” of that state with fine-grained

View File

@@ -107,27 +107,28 @@ fn clear() {
test_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=10 step=1/> },
);
}
```
Well use some manual DOM operations to grab the `<div>` that wraps
the whole component, as well as the `clear` button.
```rust
// now we extract the buttons by iterating over the DOM
// this would be easier if they had IDs
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
// now we extract the buttons by iterating over the DOM
// this would be easier if they had IDs
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
```
Now we can use ordinary DOM APIs to simulate user interaction.
```rust
// now let's click the `clear` button
clear.click();
// now let's click the `clear` button
clear.click();
```
You can test individual DOM element attributes or text node values. Sometimes
@@ -135,27 +136,27 @@ I like to test the whole view at once. We can do this by testing the elements
`outerHTML` against our expectations.
```rust
assert_eq!(
div.outer_html(),
// here we spawn a mini reactive system to render the test case
run_scope(create_runtime(), |cx| {
// it's as if we're creating it with a value of 0, right?
let (value, set_value) = create_signal(cx, 0);
assert_eq!(
div.outer_html(),
// here we spawn a mini reactive system to render the test case
run_scope(create_runtime(), |cx| {
// it's as if we're creating it with a value of 0, right?
let (value, set_value) = create_signal(cx, 0);
// we can remove the event listeners because they're not rendered to HTML
view! { cx,
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
// the view returned an HtmlElement<Div>, which is a smart pointer for
// a DOM element. So we can still just call .outer_html()
.outer_html()
})
);
// we can remove the event listeners because they're not rendered to HTML
view! { cx,
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
// the view returned an HtmlElement<Div>, which is a smart pointer for
// a DOM element. So we can still just call .outer_html()
.outer_html()
})
);
```
That test involved us manually replicating the `view` thats inside the component.
@@ -164,15 +165,14 @@ with the initial value `0`. This is where our wrapping element comes in: Ill
the wrappers `innerHTML` against another comparison case.
```rust
assert_eq!(test_wrapper.inner_html(), {
let comparison_wrapper = document.create_element("section").unwrap();
leptos::mount_to(
comparison_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
);
comparison_wrapper.inner_html()
});
}
assert_eq!(test_wrapper.inner_html(), {
let comparison_wrapper = document.create_element("section").unwrap();
leptos::mount_to(
comparison_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
);
comparison_wrapper.inner_html()
});
```
This is only a very limited introduction to testing. But I hope its useful as you begin to build applications.

View File

@@ -20,6 +20,12 @@ fn App(cx: Scope) -> impl IntoView {
on:click=move |_| {
set_count.update(|n| *n += 1);
}
>
"Click me: "
{move || count()}
</button>
}
}
```
So far, this is just the example from the last chapter.

View File

@@ -24,6 +24,7 @@ view! {
max="50"
value=double_count
/>
}
```
But of course, this doesnt scale very well. If you want to add a third progress

View File

@@ -1,3 +1,3 @@
[[proxy]]
rewrite = "/api/"
backend = "http://0.0.0.0:3000/"
backend = "http://127.0.0.1:3000/"

View File

@@ -28,19 +28,7 @@ pub fn RouterExample(cx: Scope) -> impl IntoView {
</nav>
<main>
<Routes>
<Route
path=""
view=move |cx| view! { cx, <ContactList/> }
>
<Route
path=":id"
view=move |cx| view! { cx, <Contact/> }
/>
<Route
path="/"
view=move |_| view! { cx, <p>"Select a contact."</p> }
/>
</Route>
<ContactRoutes/>
<Route
path="about"
view=move |cx| view! { cx, <About/> }
@@ -59,6 +47,27 @@ pub fn RouterExample(cx: Scope) -> impl IntoView {
}
}
// You can define other routes in their own component.
// Use a #[component(transparent)] that returns a <Route/>.
#[component(transparent)]
pub fn ContactRoutes(cx: Scope) -> impl IntoView {
view! { cx,
<Route
path=""
view=move |cx| view! { cx, <ContactList/> }
>
<Route
path=":id"
view=move |cx| view! { cx, <Contact/> }
/>
<Route
path="/"
view=move |_| view! { cx, <p>"Select a contact."</p> }
/>
</Route>
}
}
#[component]
pub fn ContactList(cx: Scope) -> impl IntoView {
log::debug!("rendering <ContactList/>");

View File

@@ -20,7 +20,7 @@ use leptos::{
leptos_server::{server_fn_by_path, Payload},
*,
};
use leptos_integration_utils::{build_async_response, html_parts};
use leptos_integration_utils::{build_async_response, html_parts_separated};
use leptos_meta::*;
use leptos_router::*;
use parking_lot::RwLock;
@@ -346,8 +346,9 @@ where
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream_in_order], and includes everything described in
/// the documentation for that function.
/// The HTML stream is rendered using
/// [render_to_stream_in_order](leptos::ssr::render_to_stream_in_order),
/// and includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
@@ -409,8 +410,8 @@ where
/// The provides a [MetaContext] and a [RouterIntegrationContext] to the apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_string_async], and includes everything described in
/// the documentation for that function.
/// The HTML stream is rendered using [render_to_string_async](leptos::ssr::render_to_string_async), and
/// includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
@@ -728,7 +729,7 @@ async fn stream_app(
let (stream, runtime, scope) =
render_to_stream_with_prefix_undisposed_with_context(
app,
move |cx| generate_head_metadata(cx).into(),
move |cx| generate_head_metadata_separated(cx).1.into(),
additional_context,
);
@@ -745,7 +746,7 @@ async fn stream_app_in_order(
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
app,
move |cx| {
generate_head_metadata(cx).into()
generate_head_metadata_separated(cx).1.into()
},
additional_context,
);
@@ -762,7 +763,7 @@ async fn build_stream_response(
) -> HttpResponse {
let cx = leptos::Scope { runtime, id: scope };
let (head, tail) =
html_parts(options, use_context::<MetaContext>(cx).as_ref());
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
let mut stream = Box::pin(
futures::stream::once(async move { head.clone() })

View File

@@ -27,8 +27,8 @@ use leptos::{
ssr::*,
*,
};
use leptos_integration_utils::{build_async_response, html_parts};
use leptos_meta::{generate_head_metadata, MetaContext};
use leptos_integration_utils::{build_async_response, html_parts_separated};
use leptos_meta::{generate_head_metadata_separated, MetaContext};
use leptos_router::*;
use parking_lot::RwLock;
use std::{io, pin::Pin, sync::Arc};
@@ -147,8 +147,9 @@ pub async fn generate_request_and_parts(
(request, request_parts)
}
/// A struct to hold the http::request::Request and allow users to take ownership of it
/// Required by Request not being Clone. See this issue for eventual resolution: https://github.com/hyperium/http/pull/574
/// A struct to hold the [`http::request::Request`] and allow users to take ownership of it
/// Required by `Request` not being `Clone`. See
/// [this issue](https://github.com/hyperium/http/pull/574) for eventual resolution:
#[derive(Debug, Default)]
pub struct LeptosRequest<B>(Arc<RwLock<Option<Request<B>>>>);
@@ -158,12 +159,12 @@ impl<B> Clone for LeptosRequest<B> {
}
}
impl<B> LeptosRequest<B> {
/// Overwrite the contents of a LeptosRequest with a new Request<B>
/// Overwrite the contents of a LeptosRequest with a new `Request<B>`
pub fn overwrite(&self, req: Option<Request<B>>) {
let mut writable = self.0.write();
*writable = req
}
/// Consume the inner Request<B> inside the LeptosRequest and return it
/// Consume the inner `Request<B>` inside the LeptosRequest and return it
///```rust, ignore
/// use axum::{
/// RequestPartsExt,
@@ -199,7 +200,8 @@ impl<B> LeptosRequest<B> {
}
/// Generate a wrapper for the http::Request::Request type that allows one to
/// process it, access the body, and use axum Extractors on it.
/// Required by Request not being Clone. See this issue for eventual resolution: https://github.com/hyperium/http/pull/574
/// Required by Request not being Clone. See
/// [this issue](https://github.com/hyperium/http/pull/574) for eventual resolution:
pub async fn generate_leptos_request<B>(req: Request<B>) -> LeptosRequest<B>
where
B: Default + std::fmt::Debug,
@@ -653,7 +655,7 @@ where
let (bundle, runtime, scope) =
leptos::leptos_dom::ssr::render_to_stream_with_prefix_undisposed_with_context(
app,
|cx| generate_head_metadata(cx).into(),
|cx| generate_head_metadata_separated(cx).1.into(),
add_context,
);
@@ -711,7 +713,7 @@ async fn forward_stream(
) {
let cx = Scope { runtime, id: scope };
let (head, tail) =
html_parts(options, use_context::<MetaContext>(cx).as_ref());
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
_ = tx.send(head).await;
let mut shell = Box::pin(bundle);
@@ -822,7 +824,7 @@ where
let (bundle, runtime, scope) =
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
app,
|cx| generate_head_metadata(cx).into(),
|cx| generate_head_metadata_separated(cx).1.into(),
add_context,
);

View File

@@ -3,25 +3,10 @@ use leptos::{use_context, RuntimeId, ScopeId};
use leptos_config::LeptosOptions;
use leptos_meta::MetaContext;
pub fn html_parts(
options: &LeptosOptions,
meta: Option<&MetaContext>,
) -> (String, &'static str) {
let pkg_path = &options.site_pkg_dir;
let output_name = &options.output_name;
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to maintain compatibility with it's default options
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
let mut wasm_output_name = output_name.clone();
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
wasm_output_name.push_str("_bg");
}
fn autoreload(options: &LeptosOptions) -> String {
let site_ip = &options.site_addr.ip().to_string();
let reload_port = options.reload_port;
let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() {
match std::env::var("LEPTOS_WATCH").is_ok() {
true => format!(
r#"
<script crossorigin="">(function () {{
@@ -52,7 +37,25 @@ pub fn html_parts(
leptos_hot_reload::HOT_RELOAD_JS
),
false => "".to_string(),
};
}
}
pub fn html_parts(
options: &LeptosOptions,
meta: Option<&MetaContext>,
) -> (String, &'static str) {
let pkg_path = &options.site_pkg_dir;
let output_name = &options.output_name;
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
let mut wasm_output_name = output_name.clone();
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
wasm_output_name.push_str("_bg");
}
let leptos_autoreload = autoreload(options);
let html_metadata =
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
@@ -72,6 +75,46 @@ pub fn html_parts(
(head, tail)
}
pub fn html_parts_separated(
options: &LeptosOptions,
meta: Option<&MetaContext>,
) -> (String, &'static str) {
let pkg_path = &options.site_pkg_dir;
let output_name = &options.output_name;
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
let mut wasm_output_name = output_name.clone();
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
wasm_output_name.push_str("_bg");
}
let leptos_autoreload = autoreload(options);
let html_metadata =
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
let head = meta
.as_ref()
.map(|meta| meta.dehydrate())
.unwrap_or_default();
let head = format!(
r#"<!DOCTYPE html>
<html{html_metadata}>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{head}
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
{leptos_autoreload}
"#
);
let tail = "</body></html>";
(head, tail)
}
pub async fn build_async_response(
stream: impl Stream<Item = String> + 'static,
options: &LeptosOptions,
@@ -86,7 +129,7 @@ pub async fn build_async_response(
let cx = leptos::Scope { runtime, id: scope };
let (head, tail) =
html_parts(options, use_context::<MetaContext>(cx).as_ref());
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
// in async, we load the meta content *now*, after the suspenses have resolved
let meta = use_context::<MetaContext>(cx);

View File

@@ -17,8 +17,8 @@ use leptos::{
ssr::*,
*,
};
use leptos_integration_utils::{build_async_response, html_parts};
use leptos_meta::{generate_head_metadata, MetaContext};
use leptos_integration_utils::{build_async_response, html_parts_separated};
use leptos_meta::{generate_head_metadata_separated, MetaContext};
use leptos_router::*;
use parking_lot::RwLock;
use std::{pin::Pin, sync::Arc};
@@ -536,7 +536,7 @@ where
let (bundle, runtime, scope) =
leptos::leptos_dom::ssr::render_to_stream_with_prefix_undisposed_with_context(
app,
|cx| generate_head_metadata(cx).into(),
|cx| generate_head_metadata_separated(cx).1.into(),
add_context,
);
@@ -593,7 +593,7 @@ async fn forward_stream(
) {
let cx = Scope { runtime, id: scope };
let (head, tail) =
html_parts(options, use_context::<MetaContext>(cx).as_ref());
html_parts_separated(options, use_context::<MetaContext>(cx).as_ref());
_ = tx.send(head).await;
let mut shell = Box::pin(bundle);
@@ -700,7 +700,7 @@ where
let (bundle, runtime, scope) =
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
app,
|cx| generate_head_metadata(cx).into(),
|cx| generate_head_metadata_separated(cx).1.into(),
add_context,
);

View File

@@ -781,9 +781,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
{
let mut this = self;
let this = classes_signal()
classes_signal()
.into_iter()
.map(Into::into)
.flat_map(|classes| {
@@ -792,9 +790,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
.map(ToString::to_string)
.collect::<SmallVec<[_; 4]>>()
})
.fold(this, |this, class| this.class(class, true));
this
.fold(self, |this, class| this.class(class, true))
}
}

View File

@@ -94,12 +94,7 @@ pub(crate) fn property_helper(
create_render_effect(cx, move |old| {
let new = f();
let prop_name = wasm_bindgen::intern(&name);
if old.as_ref() != Some(&new)
&& !(old.is_none()
&& new == wasm_bindgen::JsValue::UNDEFINED)
{
property_expression(&el, prop_name, new.clone())
}
property_expression(&el, prop_name, new.clone());
new
});
}

View File

@@ -133,7 +133,7 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
let runtime = create_runtime();
let (
(shell, prefix, pending_resources, pending_fragments, serializers),
(shell, pending_resources, pending_fragments, serializers),
scope,
disposer,
) = run_scope_undisposed(runtime, {
@@ -146,27 +146,74 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
let resources = cx.pending_resources();
let pending_resources = serde_json::to_string(&resources).unwrap();
let prefix = prefix(cx);
(
shell,
prefix,
pending_resources,
cx.pending_fragments(),
cx.serialization_resolvers(),
)
}
});
let cx = Scope { runtime, id: scope };
let blocking_fragments = FuturesUnordered::new();
let fragments = FuturesUnordered::new();
for (fragment_id, (fut, _)) in pending_fragments {
fragments.push(async move { (fragment_id, fut.await) })
for (fragment_id, data) in pending_fragments {
if data.should_block {
blocking_fragments
.push(async move { (fragment_id, data.out_of_order.await) });
} else {
fragments
.push(async move { (fragment_id, data.out_of_order.await) });
}
}
// resources and fragments
// stream HTML for each <Suspense/> as it resolves
// TODO can remove id_before_suspense entirely now
let fragments = fragments.map(|(fragment_id, html)| {
let fragments = fragments_to_chunks(fragments);
// stream data for each Resource as it resolves
let resources = render_serializers(serializers);
// HTML for the view function and script to store resources
let stream = futures::stream::once(async move {
let mut blocking = String::new();
let mut blocking_fragments = fragments_to_chunks(blocking_fragments);
while let Some(fragment) = blocking_fragments.next().await {
blocking.push_str(&fragment);
}
let prefix = prefix(cx);
format!(
r#"
{prefix}
{shell}
<script>
__LEPTOS_PENDING_RESOURCES = {pending_resources};
__LEPTOS_RESOLVED_RESOURCES = new Map();
__LEPTOS_RESOURCE_RESOLVERS = new Map();
</script>
{blocking}
"#
)
})
// TODO these should be combined again in a way that chains them appropriately
// such that individual resources can resolve before all fragments are done
.chain(fragments)
.chain(resources)
// dispose of the root scope
.chain(futures::stream::once(async move {
disposer.dispose();
Default::default()
}));
(stream, runtime, scope)
}
fn fragments_to_chunks(
fragments: impl Stream<Item = (String, String)>,
) -> impl Stream<Item = String> {
fragments.map(|(fragment_id, html)| {
format!(
r#"
<template id="{fragment_id}f">{html}</template>
@@ -191,35 +238,7 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
</script>
"#
)
});
// stream data for each Resource as it resolves
let resources = render_serializers(serializers);
// HTML for the view function and script to store resources
let stream = futures::stream::once(async move {
format!(
r#"
{prefix}
{shell}
<script>
__LEPTOS_PENDING_RESOURCES = {pending_resources};
__LEPTOS_RESOLVED_RESOURCES = new Map();
__LEPTOS_RESOURCE_RESOLVERS = new Map();
</script>
"#
)
})
// TODO these should be combined again in a way that chains them appropriately
// such that individual resources can resolve before all fragments are done
.chain(fragments)
.chain(resources)
// dispose of the root scope
.chain(futures::stream::once(async move {
disposer.dispose();
Default::default()
}));
(stream, runtime, scope)
}
impl View {
@@ -509,12 +528,14 @@ pub(crate) fn render_serializers(
) -> impl Stream<Item = String> {
serializers.map(|(id, json)| {
let id = serde_json::to_string(&id).unwrap();
let json = json.replace('<', "\\u003c");
format!(
r#"<script>
var val = {json:?};
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
__LEPTOS_RESOURCE_RESOLVERS.get({id})({json:?})
__LEPTOS_RESOURCE_RESOLVERS.get({id})(val)
}} else {{
__LEPTOS_RESOLVED_RESOURCES.set({id}, {json:?});
__LEPTOS_RESOLVED_RESOURCES.set({id}, val);
}}
</script>"#,
)

View File

@@ -15,7 +15,7 @@ use leptos_reactive::{
create_runtime, run_scope_undisposed, suspense::StreamChunk, RuntimeId,
Scope, ScopeId,
};
use std::borrow::Cow;
use std::{borrow::Cow, collections::VecDeque};
/// Renders a view to HTML, waiting to return until all `async` [Resource](leptos_reactive::Resource)s
/// loaded in `<Suspense/>` elements have finished loading.
@@ -80,29 +80,48 @@ pub fn render_to_stream_in_order_with_prefix_undisposed_with_context(
// create the runtime
let runtime = create_runtime();
let ((chunks, prefix, pending_resources, serializers), scope_id, disposer) =
run_scope_undisposed(runtime, |cx| {
// add additional context
additional_context(cx);
let (
(
blocking_fragments_ready,
chunks,
prefix,
pending_resources,
serializers,
),
scope_id,
disposer,
) = run_scope_undisposed(runtime, |cx| {
// add additional context
additional_context(cx);
// render view and return chunks
let view = view(cx);
// render view and return chunks
let view = view(cx);
let prefix = prefix(cx);
(
view.into_stream_chunks(cx),
prefix,
serde_json::to_string(&cx.pending_resources()).unwrap(),
cx.serialization_resolvers(),
)
});
(
cx.blocking_fragments_ready(),
view.into_stream_chunks(cx),
prefix,
serde_json::to_string(&cx.pending_resources()).unwrap(),
cx.serialization_resolvers(),
)
});
let cx = Scope {
runtime,
id: scope_id,
};
let (tx, rx) = futures::channel::mpsc::unbounded();
let (prefix_tx, prefix_rx) = futures::channel::oneshot::channel();
leptos_reactive::spawn_local(async move {
handle_chunks(tx, chunks).await;
blocking_fragments_ready.await;
let remaining_chunks = handle_blocking_chunks(tx.clone(), chunks).await;
let prefix = prefix(cx);
prefix_tx.send(prefix).expect("to send prefix");
handle_chunks(tx, remaining_chunks).await;
});
let stream = futures::stream::once(async move {
let prefix = prefix_rx.await.expect("to receive prefix");
format!(
r#"
{prefix}
@@ -126,18 +145,61 @@ pub fn render_to_stream_in_order_with_prefix_undisposed_with_context(
}
#[async_recursion(?Send)]
async fn handle_chunks(tx: UnboundedSender<String>, chunks: Vec<StreamChunk>) {
async fn handle_blocking_chunks(
tx: UnboundedSender<String>,
mut queued_chunks: VecDeque<StreamChunk>,
) -> VecDeque<StreamChunk> {
let mut buffer = String::new();
while let Some(chunk) = queued_chunks.pop_front() {
match chunk {
StreamChunk::Sync(sync) => buffer.push_str(&sync),
StreamChunk::Async {
chunks,
should_block,
} => {
if should_block {
// add static HTML before the Suspense and stream it down
tx.unbounded_send(std::mem::take(&mut buffer))
.expect("failed to send async HTML chunk");
// send the inner stream
let suspended = chunks.await;
handle_blocking_chunks(tx.clone(), suspended).await;
} else {
// TODO: should probably first check if there are any *other* blocking chunks
queued_chunks.push_front(StreamChunk::Async {
chunks,
should_block: false,
});
break;
}
}
}
}
// send final sync chunk
tx.unbounded_send(std::mem::take(&mut buffer))
.expect("failed to send final HTML chunk");
queued_chunks
}
#[async_recursion(?Send)]
async fn handle_chunks(
tx: UnboundedSender<String>,
chunks: VecDeque<StreamChunk>,
) {
let mut buffer = String::new();
for chunk in chunks {
match chunk {
StreamChunk::Sync(sync) => buffer.push_str(&sync),
StreamChunk::Async(suspended) => {
StreamChunk::Async { chunks, .. } => {
// add static HTML before the Suspense and stream it down
tx.unbounded_send(std::mem::take(&mut buffer))
.expect("failed to send async HTML chunk");
// send the inner stream
let suspended = suspended.await;
let suspended = chunks.await;
handle_chunks(tx.clone(), suspended).await;
}
}
@@ -149,8 +211,8 @@ async fn handle_chunks(tx: UnboundedSender<String>, chunks: Vec<StreamChunk>) {
impl View {
/// Renders the view into a set of HTML chunks that can be streamed.
pub fn into_stream_chunks(self, cx: Scope) -> Vec<StreamChunk> {
let mut chunks = Vec::new();
pub fn into_stream_chunks(self, cx: Scope) -> VecDeque<StreamChunk> {
let mut chunks = VecDeque::new();
self.into_stream_chunks_helper(cx, &mut chunks);
chunks
}
@@ -158,37 +220,42 @@ impl View {
fn into_stream_chunks_helper(
self,
cx: Scope,
chunks: &mut Vec<StreamChunk>,
chunks: &mut VecDeque<StreamChunk>,
) {
match self {
View::Suspense(id, _) => {
let id = id.to_string();
if let Some((_, fragment)) = cx.take_pending_fragment(&id) {
chunks.push(StreamChunk::Async(fragment));
if let Some(data) = cx.take_pending_fragment(&id) {
chunks.push_back(StreamChunk::Async {
chunks: data.in_order,
should_block: data.should_block,
});
}
}
View::Text(node) => chunks.push(StreamChunk::Sync(node.content)),
View::Text(node) => {
chunks.push_back(StreamChunk::Sync(node.content))
}
View::Component(node) => {
cfg_if! {
if #[cfg(debug_assertions)] {
let name = crate::ssr::to_kebab_case(&node.name);
chunks.push(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-start-->"#, HydrationCtx::to_string(&node.id, false)).into()));
chunks.push_back(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-start-->"#, HydrationCtx::to_string(&node.id, false)).into()));
for child in node.children {
child.into_stream_chunks_helper(cx, chunks);
}
chunks.push(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-end-->"#, HydrationCtx::to_string(&node.id, true)).into()));
chunks.push_back(StreamChunk::Sync(format!(r#"<!--hk={}|leptos-{name}-end-->"#, HydrationCtx::to_string(&node.id, true)).into()));
} else {
for child in node.children {
child.into_stream_chunks_helper(cx, chunks);
}
chunks.push(StreamChunk::Sync(format!(r#"<!--hk={}-->"#, HydrationCtx::to_string(&node.id, true)).into()))
chunks.push_back(StreamChunk::Sync(format!(r#"<!--hk={}-->"#, HydrationCtx::to_string(&node.id, true)).into()))
}
}
}
View::Element(el) => {
#[cfg(debug_assertions)]
if let Some(id) = &el.view_marker {
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!("<!--leptos-view|{id}|open-->").into(),
));
}
@@ -196,7 +263,7 @@ impl View {
for chunk in el_chunks {
match chunk {
StringOrView::String(string) => {
chunks.push(StreamChunk::Sync(string))
chunks.push_back(StreamChunk::Sync(string))
}
StringOrView::View(view) => {
view().into_stream_chunks_helper(cx, chunks);
@@ -232,18 +299,18 @@ impl View {
.join("");
if el.is_void {
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!("<{tag_name}{attrs}/>").into(),
));
} else if let Some(inner_html) = inner_html {
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!(
"<{tag_name}{attrs}>{inner_html}</{tag_name}>"
)
.into(),
));
} else {
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!("<{tag_name}{attrs}>").into(),
));
@@ -255,20 +322,20 @@ impl View {
}
}
ElementChildren::InnerHtml(inner_html) => {
chunks.push(StreamChunk::Sync(inner_html));
chunks.push_back(StreamChunk::Sync(inner_html));
}
// handled above
ElementChildren::Chunks(_) => unreachable!(),
}
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!("</{tag_name}>").into(),
));
}
}
#[cfg(debug_assertions)]
if let Some(id) = &el.view_marker {
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!("<!--leptos-view|{id}|close-->").into(),
));
}
@@ -280,10 +347,10 @@ impl View {
u.id.clone(),
"",
false,
Box::new(move |chunks: &mut Vec<StreamChunk>| {
Box::new(move |chunks: &mut VecDeque<StreamChunk>| {
#[cfg(debug_assertions)]
{
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!(
"<!--hk={}|leptos-unit-->",
HydrationCtx::to_string(&u.id, true)
@@ -293,7 +360,7 @@ impl View {
}
#[cfg(not(debug_assertions))]
chunks.push(StreamChunk::Sync(
chunks.push_back(StreamChunk::Sync(
format!(
"<!--hk={}-->",
HydrationCtx::to_string(&u.id, true)
@@ -301,7 +368,7 @@ impl View {
.into(),
));
})
as Box<dyn FnOnce(&mut Vec<StreamChunk>)>,
as Box<dyn FnOnce(&mut VecDeque<StreamChunk>)>,
),
CoreComponent::DynChild(node) => {
let child = node.child.take();
@@ -309,34 +376,39 @@ impl View {
node.id,
"dyn-child",
true,
Box::new(move |chunks: &mut Vec<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 {
chunks.push(
if !cfg!(debug_assertions) {
StreamChunk::Sync(
format!("<!>{}", t.content)
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 {
chunks.push_back(
if !cfg!(debug_assertions) {
StreamChunk::Sync(
format!(
"<!>{}",
t.content
)
.into(),
)
} else {
StreamChunk::Sync(t.content)
},
);
} else {
child.into_stream_chunks_helper(
cx, chunks,
);
)
} else {
StreamChunk::Sync(t.content)
},
);
} else {
child.into_stream_chunks_helper(
cx, chunks,
);
}
}
}
})
as Box<dyn FnOnce(&mut Vec<StreamChunk>)>,
},
)
as Box<dyn FnOnce(&mut VecDeque<StreamChunk>)>,
)
}
CoreComponent::Each(node) => {
@@ -345,33 +417,40 @@ impl View {
node.id,
"each",
true,
Box::new(move |chunks: &mut Vec<StreamChunk>| {
for node in children.into_iter().flatten() {
let id = node.id;
Box::new(
move |chunks: &mut VecDeque<StreamChunk>| {
for node in children.into_iter().flatten() {
let id = node.id;
#[cfg(debug_assertions)]
{
chunks.push(StreamChunk::Sync(
format!(
#[cfg(debug_assertions)]
{
chunks.push_back(
StreamChunk::Sync(
format!(
"<!--hk={}|leptos-each-item-start-->",
HydrationCtx::to_string(&id, false)
)
.into(),
));
node.child.into_stream_chunks_helper(
cx, chunks,
);
chunks.push(StreamChunk::Sync(
format!(
.into(),
),
);
node.child
.into_stream_chunks_helper(
cx, chunks,
);
chunks.push_back(
StreamChunk::Sync(
format!(
"<!--hk={}|leptos-each-item-end-->",
HydrationCtx::to_string(&id, true)
)
.into(),
));
.into(),
),
);
}
}
}
})
as Box<dyn FnOnce(&mut Vec<StreamChunk>)>,
},
)
as Box<dyn FnOnce(&mut VecDeque<StreamChunk>)>,
)
}
};
@@ -379,13 +458,13 @@ impl View {
if wrap {
cfg_if! {
if #[cfg(debug_assertions)] {
chunks.push(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-start-->", HydrationCtx::to_string(&id, false)).into()));
chunks.push_back(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-start-->", HydrationCtx::to_string(&id, false)).into()));
content(chunks);
chunks.push(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-end-->", HydrationCtx::to_string(&id, true)).into()));
chunks.push_back(StreamChunk::Sync(format!("<!--hk={}|leptos-{name}-end-->", HydrationCtx::to_string(&id, true)).into()));
} else {
let _ = name;
content(chunks);
chunks.push(StreamChunk::Sync(format!("<!--hk={}-->", HydrationCtx::to_string(&id, true)).into()))
chunks.push_back(StreamChunk::Sync(format!("<!--hk={}-->", HydrationCtx::to_string(&id, true)).into()))
}
}
} else {

View File

@@ -43,7 +43,7 @@ impl Parse for Model {
"this method requires a `Scope` parameter";
help = "try `fn {}(cx: Scope, /* ... */)`", item.sig.ident
);
} else if props[0].ty != parse_quote!(Scope) {
} else if !is_valid_scope_type(&props[0].ty) {
abort!(
item.sig.inputs,
"this method requires a `Scope` parameter";
@@ -68,7 +68,7 @@ impl Parse for Model {
});
// Make sure return type is correct
if item.sig.output != parse_quote!(-> impl IntoView) {
if !is_valid_into_view_return_type(&item.sig.output) {
abort!(
item.sig,
"return type is incorrect";
@@ -206,7 +206,7 @@ impl ToTokens for Model {
#tracing_instrument_attr
#vis fn #name #generics (
#[allow(unused_variables)]
#scope_name: Scope,
#scope_name: ::leptos::Scope,
props: #props_name #generics
) #ret #(+ #lifetimes)*
#where_clause
@@ -436,7 +436,7 @@ impl ToTokens for TypedBuilderOpts {
fn prop_builder_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
props
.iter()
.filter(|Prop { ty, .. }| *ty != parse_quote!(Scope))
.filter(|Prop { ty, .. }| !is_valid_scope_type(ty))
.map(|prop| {
let Prop {
docs,
@@ -463,7 +463,7 @@ fn prop_builder_fields(vis: &Visibility, props: &[Prop]) -> TokenStream {
fn prop_names(props: &[Prop]) -> TokenStream {
props
.iter()
.filter(|Prop { ty, .. }| *ty != parse_quote!(Scope))
.filter(|Prop { ty, .. }| !is_valid_scope_type(ty))
.map(|Prop { name, .. }| quote! { #name, })
.collect()
}
@@ -642,3 +642,23 @@ fn prop_to_doc(
}
}
}
fn is_valid_scope_type(ty: &Type) -> bool {
[
parse_quote!(Scope),
parse_quote!(leptos::Scope),
parse_quote!(::leptos::Scope),
]
.iter()
.any(|test| ty == test)
}
fn is_valid_into_view_return_type(ty: &ReturnType) -> bool {
[
parse_quote!(-> impl IntoView),
parse_quote!(-> impl leptos::IntoView),
parse_quote!(-> impl ::leptos::IntoView),
]
.iter()
.any(|test| ty == test)
}

View File

@@ -201,13 +201,15 @@ mod template;
/// ```
///
/// 8. You can use the `node_ref` or `_ref` attribute to store a reference to its DOM element in a
/// [NodeRef](leptos_dom::NodeRef) to use later.
/// [NodeRef](https://docs.rs/leptos/latest/leptos/struct.NodeRef.html) to use later.
/// ```rust
/// # use leptos::*;
/// # run_scope(create_runtime(), |cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// use leptos::html::Input;
///
/// let (value, set_value) = create_signal(cx, 0);
/// let my_input = NodeRef::new(cx);
/// let my_input = create_node_ref::<Input>(cx);
/// view! { cx, <input type="text" _ref=my_input/> }
/// // `my_input` now contains an `Element` that we can use anywhere
/// # ;
@@ -399,9 +401,9 @@ pub fn template(tokens: TokenStream) -> TokenStream {
///
/// The `#[component]` macro allows you to annotate plain Rust functions as components
/// and use them within your Leptos [view](crate::view!) as if they were custom HTML elements. The
/// component function takes a [Scope](leptos_reactive::Scope) and any number of other arguments.
/// When you use the component somewhere else, the names of its arguments are the names
/// of the properties you use in the [view](crate::view!) macro.
/// component function takes a [Scope](https://docs.rs/leptos/latest/leptos/struct.Scope.html)
/// and any number of other arguments. When you use the component somewhere else,
/// the names of its arguments are the names of the properties you use in the [view](crate::view!) macro.
///
/// Every component function should have the return type `-> impl IntoView`.
///
@@ -576,8 +578,10 @@ pub fn template(tokens: TokenStream) -> TokenStream {
/// You can use the `#[prop]` attribute on individual component properties (function arguments) to
/// customize the types that component property can receive. You can use the following attributes:
/// * `#[prop(into)]`: This will call `.into()` on any value passed into the component prop. (For example,
/// you could apply `#[prop(into)]` to a prop that takes [Signal](leptos_reactive::Signal), which would
/// allow users to pass a [ReadSignal](leptos_reactive::ReadSignal) or [RwSignal](leptos_reactive::RwSignal)
/// you could apply `#[prop(into)]` to a prop that takes
/// [Signal](https://docs.rs/leptos/latest/leptos/struct.Signal.html), which would
/// allow users to pass a [ReadSignal](https://docs.rs/leptos/latest/leptos/struct.ReadSignal.html) or
/// [RwSignal](https://docs.rs/leptos/latest/leptos/struct.RwSignal.html)
/// and automatically convert it.)
/// * `#[prop(optional)]`: If the user does not specify this property when they use the component,
/// it will be set to its default value. If the property type is `Option<T>`, values should be passed
@@ -640,8 +644,8 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
.into()
}
/// Declares that a function is a [server function](leptos_server). This means that
/// its body will only run on the server, i.e., when the `ssr` feature is enabled.
/// Declares that a function is a [server function](https://docs.rs/server_fn/latest/server_fn/index.html).
/// This means that its body will only run on the server, i.e., when the `ssr` feature is enabled.
///
/// If you call a server function from the client (i.e., when the `csr` or `hydrate` features
/// are enabled), it will instead make a network request to the server.
@@ -657,7 +661,8 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// work without WebAssembly, the encoding must be `"Url"`.
///
/// The server function itself can take any number of arguments, each of which should be serializable
/// and deserializable with `serde`. Optionally, its first argument can be a Leptos [Scope](leptos_reactive::Scope),
/// and deserializable with `serde`. Optionally, its first argument can be a Leptos
/// [Scope](https://docs.rs/leptos/latest/leptos/struct.Scope.html),
/// which will be injected *on the server side.* This can be used to inject the raw HTTP request or other
/// server-side context into the server function.
///
@@ -680,7 +685,7 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done
/// inside the function body cant fail, the processes of serialization/deserialization and the
/// network call are fallible.
/// - **Return types must be [Serializable](leptos_reactive::Serializable).**
/// - **Return types must be [Serializable](https://docs.rs/leptos/latest/leptos/trait.Serializable.html).**
/// This should be fairly obvious: we have to serialize arguments to send them to the server, and we
/// need to deserialize the result to return it to the client.
/// - **Arguments must be implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html)
@@ -688,8 +693,8 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// They are serialized as an `application/x-www-form-urlencoded`
/// form data using [`serde_urlencoded`](https://docs.rs/serde_urlencoded/latest/serde_urlencoded/) or as `application/cbor`
/// using [`cbor`](https://docs.rs/cbor/latest/cbor/).
/// - **The [Scope](leptos_reactive::Scope) comes from the server.** Optionally, the first argument of a server function
/// can be a Leptos [Scope](leptos_reactive::Scope). This scope can be used to inject dependencies like the HTTP request
/// - **The `Scope` comes from the server.** Optionally, the first argument of a server function
/// can be a Leptos `Scope`. This scope can be used to inject dependencies like the HTTP request
/// or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client.
#[proc_macro_attribute]
pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {

View File

@@ -1392,7 +1392,7 @@ fn is_math_ml_element(tag: &str) -> bool {
}
fn is_ambiguous_element(tag: &str) -> bool {
tag == "a" || tag == "script"
tag == "a" || tag == "script" || tag == "title"
}
fn parse_event(event_name: &str) -> (&str, bool) {

View File

@@ -1,5 +1,6 @@
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
t.compile_fail("tests/ui/component.rs");
t.compile_fail("tests/ui/component_absolute.rs");
}

View File

@@ -0,0 +1,52 @@
#[::leptos::component]
fn missing_scope() {}
#[::leptos::component]
fn missing_return_type(cx: ::leptos::Scope) {}
#[::leptos::component]
fn unknown_prop_option(cx: ::leptos::Scope, #[prop(hello)] test: bool) -> impl ::leptos::IntoView {}
#[::leptos::component]
fn optional_and_optional_no_strip(
cx: Scope,
#[prop(optional, optional_no_strip)] conflicting: bool,
) -> impl IntoView {
}
#[::leptos::component]
fn optional_and_strip_option(
cx: ::leptos::Scope,
#[prop(optional, strip_option)] conflicting: bool,
) -> impl ::leptos::IntoView {
}
#[::leptos::component]
fn optional_no_strip_and_strip_option(
cx: ::leptos::Scope,
#[prop(optional_no_strip, strip_option)] conflicting: bool,
) -> impl ::leptos::IntoView {
}
#[::leptos::component]
fn default_without_value(
cx: ::leptos::Scope,
#[prop(default)] default: bool,
) -> impl ::leptos::IntoView {
}
#[::leptos::component]
fn default_with_invalid_value(
cx: ::leptos::Scope,
#[prop(default= |)] default: bool,
) -> impl ::leptos::IntoView {
}
#[::leptos::component]
pub fn using_the_view_macro(cx: ::leptos::Scope) -> impl ::leptos::IntoView {
::leptos::view! { cx,
"ok"
}
}
fn main() {}

View File

@@ -0,0 +1,53 @@
error: this method requires a `Scope` parameter
--> tests/ui/component_absolute.rs:2:1
|
2 | fn missing_scope() {}
| ^^^^^^^^^^^^^^^^^^
|
= help: try `fn missing_scope(cx: Scope, /* ... */)`
error: return type is incorrect
--> tests/ui/component_absolute.rs:5:1
|
5 | fn missing_return_type(cx: ::leptos::Scope) {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: return signature must be `-> impl IntoView`
error: supported fields are `optional`, `optional_no_strip`, `strip_option`, `default` and `into`
--> tests/ui/component_absolute.rs:8:52
|
8 | fn unknown_prop_option(cx: ::leptos::Scope, #[prop(hello)] test: bool) -> impl ::leptos::IntoView {}
| ^^^^^
error: `optional` conflicts with mutually exclusive `optional_no_strip`
--> tests/ui/component_absolute.rs:13:12
|
13 | #[prop(optional, optional_no_strip)] conflicting: bool,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: `optional` conflicts with mutually exclusive `strip_option`
--> tests/ui/component_absolute.rs:20:12
|
20 | #[prop(optional, strip_option)] conflicting: bool,
| ^^^^^^^^^^^^^^^^^^^^^^
error: `optional_no_strip` conflicts with mutually exclusive `strip_option`
--> tests/ui/component_absolute.rs:27:12
|
27 | #[prop(optional_no_strip, strip_option)] conflicting: bool,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: unexpected end of input, expected assignment `=`
--> tests/ui/component_absolute.rs:34:19
|
34 | #[prop(default)] default: bool,
| ^
error: unexpected end of input, expected one of: `::`, `<`, `_`, literal, `const`, `ref`, `mut`, `&`, parentheses, square brackets, `..`, `const`
= help: try `#[prop(default=5 * 10)]`
--> tests/ui/component_absolute.rs:41:22
|
41 | #[prop(default= |)] default: bool,
| ^

View File

@@ -1,20 +1,26 @@
#![forbid(unsafe_code)]
use crate::{runtime::PinnedFuture, suspense::StreamChunk, ResourceId};
use cfg_if::cfg_if;
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
pub struct SharedContext {
pub events: Vec<()>,
pub pending_resources: HashSet<ResourceId>,
pub resolved_resources: HashMap<ResourceId, String>,
#[allow(clippy::type_complexity)]
// index String is the fragment ID: tuple is
// `(
// Future of <Suspense/> HTML when resolved (out-of-order)
// Future of additional stream chunks when resolved (in-order)
// )`
pub pending_fragments:
HashMap<String, (PinnedFuture<String>, PinnedFuture<Vec<StreamChunk>>)>,
pub pending_fragments: HashMap<String, FragmentData>,
}
/// Represents its pending `<Suspense/>` fragment.
pub struct FragmentData {
/// Future that represents how it should be render for an out-of-order stream.
pub out_of_order: PinnedFuture<String>,
/// Future that represents how it should be render for an in-order stream.
pub in_order: PinnedFuture<VecDeque<StreamChunk>>,
/// Whether the stream should wait for this fragment before sending any data.
pub should_block: bool,
/// Future that will resolve when the fragment is ready.
pub is_ready: Option<PinnedFuture<()>>,
}
impl std::fmt::Debug for SharedContext {

View File

@@ -106,6 +106,73 @@ pub fn create_resource_with_initial_value<S, T, Fu>(
fetcher: impl Fn(S) -> Fu + 'static,
initial_value: Option<T>,
) -> Resource<S, T>
where
S: PartialEq + Debug + Clone + 'static,
T: Serializable + 'static,
Fu: Future<Output = T> + 'static,
{
create_resource_helper(
cx,
source,
fetcher,
initial_value,
ResourceSerialization::Serializable,
)
}
/// Creates a “blocking” [Resource](crate::Resource). When server-side rendering is used,
/// this resource will cause any `<Suspense/>` you read it under to block the initial
/// chunk of HTML from being sent to the client. This means that if you set things like
/// HTTP headers or `<head>` metadata in that `<Suspense/>`, that header material will
/// be included in the servers original response.
///
/// This causes a slow time to first byte (TTFB) but is very useful for loading data that
/// is essential to the first load. For example, a blog post page that needs to include
/// the title of the blog post in the pages initial HTML `<title>` tag for SEO reasons
/// might use a blocking resource to load blog post metadata, which will prevent the page from
/// returning until that data has loaded.
///
/// **Note**: This is not “blocking” in the sense that it blocks the current thread. Rather,
/// it is blocking in the sense that it blocks the server from sending a response.
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = ?cx.id,
ty = %std::any::type_name::<T>(),
signal_ty = %std::any::type_name::<S>(),
)
)
)]
#[track_caller]
pub fn create_blocking_resource<S, T, Fu>(
cx: Scope,
source: impl Fn() -> S + 'static,
fetcher: impl Fn(S) -> Fu + 'static,
) -> Resource<S, T>
where
S: PartialEq + Debug + Clone + 'static,
T: Serializable + 'static,
Fu: Future<Output = T> + 'static,
{
create_resource_helper(
cx,
source,
fetcher,
None,
ResourceSerialization::Blocking,
)
}
fn create_resource_helper<S, T, Fu>(
cx: Scope,
source: impl Fn() -> S + 'static,
fetcher: impl Fn(S) -> Fu + 'static,
initial_value: Option<T>,
serializable: ResourceSerialization,
) -> Resource<S, T>
where
S: PartialEq + Debug + Clone + 'static,
T: Serializable + 'static,
@@ -132,7 +199,7 @@ where
resolved: Rc::new(Cell::new(resolved)),
scheduled: Rc::new(Cell::new(false)),
suspense_contexts: Default::default(),
serializable: true,
serializable,
});
let id = with_runtime(cx.runtime, |runtime| {
@@ -256,7 +323,7 @@ where
resolved: Rc::new(Cell::new(resolved)),
scheduled: Rc::new(Cell::new(false)),
suspense_contexts: Default::default(),
serializable: false,
serializable: ResourceSerialization::Local,
});
let id = with_runtime(cx.runtime, |runtime| {
@@ -560,7 +627,19 @@ where
resolved: Rc<Cell<bool>>,
scheduled: Rc<Cell<bool>>,
suspense_contexts: Rc<RefCell<HashSet<SuspenseContext>>>,
serializable: bool,
serializable: ResourceSerialization,
}
/// Whether and how the resource can be serialized.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum ResourceSerialization {
/// Not serializable.
Local,
/// Can be serialized.
Serializable,
/// Can be serialized, and cause the first chunk to be blocked until
/// their suspense has resolved.
Blocking,
}
impl<S, T> ResourceState<S, T>
@@ -600,10 +679,14 @@ where
let serializable = self.serializable;
if let Some(suspense_cx) = &suspense_cx {
if serializable {
if serializable != ResourceSerialization::Local {
suspense_cx.has_local_only.set_value(false);
}
} else {
#[cfg(not(all(feature = "hydrate", debug_assertions)))]
{
_ = location;
}
#[cfg(all(feature = "hydrate", debug_assertions))]
crate::macros::debug_warn!(
"At {location}, you are reading a resource in `hydrate` mode \
@@ -629,7 +712,12 @@ where
// because the context has been tracked here
// on the first read, resource is already loading without having incremented
if !has_value {
s.increment(serializable);
s.increment(
serializable != ResourceSerialization::Local,
);
if serializable == ResourceSerialization::Blocking {
s.should_block.set_value(true);
}
}
}
}
@@ -670,7 +758,12 @@ where
let suspense_contexts = self.suspense_contexts.clone();
for suspense_context in suspense_contexts.borrow().iter() {
suspense_context.increment(self.serializable);
suspense_context.increment(
self.serializable != ResourceSerialization::Local,
);
if self.serializable == ResourceSerialization::Blocking {
suspense_context.should_block.set_value(true);
}
}
// run the Future
@@ -688,7 +781,9 @@ where
set_loading.update(|n| *n = false);
for suspense_context in suspense_contexts.borrow().iter() {
suspense_context.decrement(serializable);
suspense_context.decrement(
serializable != ResourceSerialization::Local,
);
}
}
})

View File

@@ -1,13 +1,17 @@
#![forbid(unsafe_code)]
use crate::{
console_warn,
hydration::FragmentData,
node::NodeId,
runtime::{with_runtime, RuntimeId},
suspense::StreamChunk,
PinnedFuture, ResourceId, StoredValueId, SuspenseContext,
};
use futures::stream::FuturesUnordered;
use std::{collections::HashMap, fmt};
use std::{
collections::{HashMap, VecDeque},
fmt,
};
#[doc(hidden)]
#[must_use = "Scope will leak memory if the disposer function is never called"]
@@ -374,7 +378,7 @@ impl Scope {
context: SuspenseContext,
key: &str,
out_of_order_resolver: impl FnOnce() -> String + 'static,
in_order_resolver: impl FnOnce() -> Vec<StreamChunk> + 'static,
in_order_resolver: impl FnOnce() -> VecDeque<StreamChunk> + 'static,
) {
use crate::create_isomorphic_effect;
use futures::StreamExt;
@@ -383,6 +387,7 @@ impl Scope {
let mut shared_context = runtime.shared_context.borrow_mut();
let (tx1, mut rx1) = futures::channel::mpsc::unbounded();
let (tx2, mut rx2) = futures::channel::mpsc::unbounded();
let (tx3, mut rx3) = futures::channel::mpsc::unbounded();
create_isomorphic_effect(*self, move |_| {
let pending = context
@@ -393,21 +398,26 @@ impl Scope {
if pending == 0 {
_ = tx1.unbounded_send(());
_ = tx2.unbounded_send(());
_ = tx3.unbounded_send(());
}
});
shared_context.pending_fragments.insert(
key.to_string(),
(
Box::pin(async move {
FragmentData {
out_of_order: Box::pin(async move {
rx1.next().await;
out_of_order_resolver()
}),
Box::pin(async move {
in_order: Box::pin(async move {
rx2.next().await;
in_order_resolver()
}),
),
should_block: context.should_block(),
is_ready: Some(Box::pin(async move {
rx3.next().await;
})),
},
);
})
}
@@ -416,10 +426,7 @@ impl Scope {
///
/// The keys are hydration IDs. Values are tuples of two pinned
/// `Future`s that return content for out-of-order and in-order streaming, respectively.
pub fn pending_fragments(
&self,
) -> HashMap<String, (PinnedFuture<String>, PinnedFuture<Vec<StreamChunk>>)>
{
pub fn pending_fragments(&self) -> HashMap<String, FragmentData> {
with_runtime(self.runtime, |runtime| {
let mut shared_context = runtime.shared_context.borrow_mut();
std::mem::take(&mut shared_context.pending_fragments)
@@ -427,14 +434,31 @@ impl Scope {
.unwrap_or_default()
}
/// A future that will resolve when all blocking fragments are ready.
pub fn blocking_fragments_ready(self) -> PinnedFuture<()> {
use futures::StreamExt;
let mut ready = with_runtime(self.runtime, |runtime| {
let mut shared_context = runtime.shared_context.borrow_mut();
let ready = FuturesUnordered::new();
for (_, data) in shared_context.pending_fragments.iter_mut() {
if data.should_block {
if let Some(is_ready) = data.is_ready.take() {
ready.push(is_ready);
}
}
}
ready
})
.unwrap_or_default();
Box::pin(async move { while ready.next().await.is_some() {} })
}
/// Takes the pending HTML for a single `<Suspense/>` node.
///
/// Returns a tuple of two pinned `Future`s that return content for out-of-order
/// and in-order streaming, respectively.
pub fn take_pending_fragment(
&self,
id: &str,
) -> Option<(PinnedFuture<String>, PinnedFuture<Vec<StreamChunk>>)> {
pub fn take_pending_fragment(&self, id: &str) -> Option<FragmentData> {
with_runtime(self.runtime, |runtime| {
let mut shared_context = runtime.shared_context.borrow_mut();
shared_context.pending_fragments.remove(id)

View File

@@ -6,7 +6,7 @@ use crate::{
RwSignal, Scope, SignalUpdate, StoredValue, WriteSignal,
};
use futures::Future;
use std::{borrow::Cow, pin::Pin};
use std::{borrow::Cow, collections::VecDeque, pin::Pin};
/// Tracks [Resource](crate::Resource)s that are read under a suspense context,
/// i.e., within a [`Suspense`](https://docs.rs/leptos_core/latest/leptos_core/fn.Suspense.html) component.
@@ -17,13 +17,21 @@ pub struct SuspenseContext {
set_pending_resources: WriteSignal<usize>,
pub(crate) pending_serializable_resources: RwSignal<usize>,
pub(crate) has_local_only: StoredValue<bool>,
pub(crate) should_block: StoredValue<bool>,
}
impl SuspenseContext {
/// Whether the suspense contains local resources at this moment, and therefore can't be
/// Whether the suspense contains local resources at this moment,
/// and therefore can't be serialized
pub fn has_local_only(&self) -> bool {
self.has_local_only.get_value()
}
/// Whether any blocking resources are read under this suspense context,
/// meaning the HTML stream should not begin until it has resolved.
pub fn should_block(&self) -> bool {
self.should_block.get_value()
}
}
impl std::hash::Hash for SuspenseContext {
@@ -46,11 +54,13 @@ impl SuspenseContext {
let (pending_resources, set_pending_resources) = create_signal(cx, 0);
let pending_serializable_resources = create_rw_signal(cx, 0);
let has_local_only = store_value(cx, true);
let should_block = store_value(cx, false);
Self {
pending_resources,
set_pending_resources,
pending_serializable_resources,
has_local_only,
should_block,
}
}
@@ -101,14 +111,19 @@ pub enum StreamChunk {
/// A chunk of synchronous HTML.
Sync(Cow<'static, str>),
/// A future that resolves to be a list of additional chunks.
Async(Pin<Box<dyn Future<Output = Vec<StreamChunk>>>>),
Async {
/// The HTML chunks this contains.
chunks: Pin<Box<dyn Future<Output = VecDeque<StreamChunk>>>>,
/// Whether this should block the stream.
should_block: bool,
},
}
impl std::fmt::Debug for StreamChunk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StreamChunk::Sync(data) => write!(f, "StreamChunk::Sync({data:?})"),
StreamChunk::Async(_) => write!(f, "StreamChunk::Async(_)"),
StreamChunk::Async { .. } => write!(f, "StreamChunk::Async(_)"),
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.2.4"
version = "0.2.5"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"

View File

@@ -0,0 +1,44 @@
use crate::TextProp;
/// A collection of additional HTML attributes to be applied to an element,
/// each of which may or may not be reactive.
#[derive(Default, Clone)]
pub struct AdditionalAttributes(pub(crate) Vec<(String, TextProp)>);
impl<I, T, U> From<I> for AdditionalAttributes
where
I: IntoIterator<Item = (T, U)>,
T: Into<String>,
U: Into<TextProp>,
{
fn from(value: I) -> Self {
Self(
value
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
}
/// Iterator over additional HTML attributes.
pub struct AdditionalAttributesIter<'a>(
std::slice::Iter<'a, (String, TextProp)>,
);
impl<'a> Iterator for AdditionalAttributesIter<'a> {
type Item = &'a (String, TextProp);
fn next(&mut self) -> Option<Self::Item> {
self.0.next()
}
}
impl<'a> IntoIterator for &'a AdditionalAttributes {
type Item = &'a (String, TextProp);
type IntoIter = AdditionalAttributesIter<'a>;
fn into_iter(self) -> Self::IntoIter {
todo!()
}
}

View File

@@ -1,4 +1,4 @@
use crate::TextProp;
use crate::{additional_attributes::AdditionalAttributes, TextProp};
use cfg_if::cfg_if;
use leptos::*;
use std::{cell::RefCell, rc::Rc};
@@ -7,15 +7,37 @@ use std::{cell::RefCell, rc::Rc};
#[derive(Clone, Default)]
pub struct BodyContext {
class: Rc<RefCell<Option<TextProp>>>,
attributes: Rc<RefCell<Option<MaybeSignal<AdditionalAttributes>>>>,
}
impl BodyContext {
/// Converts the `<body>` metadata into an HTML string.
pub fn as_string(&self) -> Option<String> {
self.class
let class = self
.class
.borrow()
.as_ref()
.map(|class| format!(" class=\"{}\"", class.get()))
.map(|val| format!("class=\"{}\"", val.get()));
let attributes = self.attributes.borrow().as_ref().map(|val| {
val.with(|val| {
val.0
.iter()
.map(|(n, v)| format!("{}=\"{}\"", n, v.get()))
.collect::<Vec<_>>()
.join(" ")
})
});
let mut val = [class, attributes]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
if val.is_empty() {
None
} else {
val.insert(0, ' ');
Some(val)
}
}
}
@@ -57,20 +79,38 @@ pub fn Body(
/// The `class` attribute on the `<body>`.
#[prop(optional, into)]
class: Option<TextProp>,
/// Arbitrary attributes to add to the `<html>`
#[prop(optional, into)]
attributes: Option<MaybeSignal<AdditionalAttributes>>,
) -> impl IntoView {
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
let el = document().body().expect("there to be a <body> element");
if let Some(class) = class {
create_render_effect(cx, move |_| {
let value = class.get();
_ = el.set_attribute("class", &value);
create_render_effect(cx, {
let el = el.clone();
move |_| {
let value = class.get();
_ = el.set_attribute("class", &value);
}
});
}
if let Some(attributes) = attributes {
let attributes = attributes.get();
for (attr_name, attr_value) in attributes.0.into_iter() {
let el = el.clone();
create_render_effect(cx, move |_|{
let value = attr_value.get();
_ = el.set_attribute(&attr_name, &value);
});
}
}
} else {
let meta = crate::use_head(cx);
*meta.body.class.borrow_mut() = class;
*meta.body.attributes.borrow_mut() = attributes;
}
}
}

View File

@@ -1,4 +1,4 @@
use crate::TextProp;
use crate::{additional_attributes::AdditionalAttributes, TextProp};
use cfg_if::cfg_if;
use leptos::*;
use std::{cell::RefCell, rc::Rc};
@@ -9,6 +9,7 @@ pub struct HtmlContext {
lang: Rc<RefCell<Option<TextProp>>>,
dir: Rc<RefCell<Option<TextProp>>>,
class: Rc<RefCell<Option<TextProp>>>,
attributes: Rc<RefCell<Option<MaybeSignal<AdditionalAttributes>>>>,
}
impl HtmlContext {
@@ -29,7 +30,16 @@ impl HtmlContext {
.borrow()
.as_ref()
.map(|val| format!("class=\"{}\"", val.get()));
let mut val = [lang, dir, class]
let attributes = self.attributes.borrow().as_ref().map(|val| {
val.with(|val| {
val.0
.iter()
.map(|(n, v)| format!("{}=\"{}\"", n, v.get()))
.collect::<Vec<_>>()
.join(" ")
})
});
let mut val = [lang, dir, class, attributes]
.into_iter()
.flatten()
.collect::<Vec<_>>()
@@ -62,7 +72,12 @@ impl std::fmt::Debug for HtmlContext {
///
/// view! { cx,
/// <main>
/// <Html lang="he" dir="rtl"/>
/// <Html
/// lang="he"
/// dir="rtl"
/// // arbitrary additional attributes can be passed via `attributes`
/// attributes=AdditionalAttributes::from(vec![("data-theme", "dark")])
/// />
/// </main>
/// }
/// }
@@ -79,6 +94,9 @@ pub fn Html(
/// The `class` attribute on the `<html>`.
#[prop(optional, into)]
class: Option<TextProp>,
/// Arbitrary attributes to add to the `<html>`
#[prop(optional, into)]
attributes: Option<MaybeSignal<AdditionalAttributes>>,
) -> impl IntoView {
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
@@ -101,16 +119,29 @@ pub fn Html(
}
if let Some(class) = class {
let el = el.clone();
create_render_effect(cx, move |_| {
let value = class.get();
_ = el.set_attribute("class", &value);
});
}
if let Some(attributes) = attributes {
let attributes = attributes.get();
for (attr_name, attr_value) in attributes.0.into_iter() {
let el = el.clone();
create_render_effect(cx, move |_|{
let value = attr_value.get();
_ = el.set_attribute(&attr_name, &value);
});
}
}
} else {
let meta = crate::use_head(cx);
*meta.html.lang.borrow_mut() = lang;
*meta.html.dir.borrow_mut() = dir;
*meta.html.class.borrow_mut() = class;
*meta.html.attributes.borrow_mut() = attributes;
}
}
}

View File

@@ -58,6 +58,7 @@ use std::{
#[cfg(any(feature = "csr", feature = "hydrate"))]
use wasm_bindgen::{JsCast, UnwrapThrowExt};
mod additional_attributes;
mod body;
mod html;
mod link;
@@ -66,6 +67,7 @@ mod script;
mod style;
mod stylesheet;
mod title;
pub use additional_attributes::*;
pub use body::*;
pub use html::*;
pub use link::*;
@@ -282,6 +284,15 @@ impl MetaContext {
/// server-side HTML rendering across crates.
#[cfg(feature = "ssr")]
pub fn generate_head_metadata(cx: Scope) -> String {
let (head, body) = generate_head_metadata_separated(cx);
format!("{head}</head><{body}>")
}
/// Extracts the metadata that should be inserted at the beginning of the `<head>` tag
/// and on the opening `<body>` tag. This is a helper function used in implementing
/// server-side HTML rendering across crates.
#[cfg(feature = "ssr")]
pub fn generate_head_metadata_separated(cx: Scope) -> (String, String) {
let meta = use_context::<MetaContext>(cx);
let head = meta
.as_ref()
@@ -291,7 +302,7 @@ pub fn generate_head_metadata(cx: Scope) -> String {
.as_ref()
.and_then(|meta| meta.body.as_string())
.unwrap_or_default();
format!("{head}</head><body{body_meta}>")
(head, format!("<body{body_meta}>"))
}
/// Describes a value that is either a static or a reactive string, i.e.,

View File

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

View File

@@ -85,6 +85,7 @@ where
// POST
if method == "post" {
ev.prevent_default();
ev.stop_propagation();
let on_response = on_response.clone();
spawn_local(async move {
@@ -144,6 +145,7 @@ where
.is_ok()
{
ev.prevent_default();
ev.stop_propagation();
}
}
};
@@ -348,6 +350,7 @@ where
}
Ok(input) => {
ev.prevent_default();
ev.stop_propagation();
multi_action.dispatch(input);
if let Some(error) = error {
error.set(None);

View File

@@ -62,6 +62,9 @@ pub fn A<H>(
/// Sets the `class` attribute on the underlying `<a>` tag, making it easier to style.
#[prop(optional, into)]
class: Option<AttributeValue>,
/// Sets the `id` attribute on the underlying `<a>` tag, making it easier to target.
#[prop(optional, into)]
id: Option<String>,
/// The nodes or elements to be shown inside the link.
children: Children,
) -> impl IntoView
@@ -75,6 +78,7 @@ where
state: Option<State>,
replace: bool,
class: Option<AttributeValue>,
id: Option<String>,
children: Children,
) -> HtmlElement<leptos::html::A> {
#[cfg(not(any(feature = "hydrate", feature = "csr")))]
@@ -109,6 +113,7 @@ where
prop:replace={replace}
aria-current=move || if is_active.get() { Some("page") } else { None }
class=class
id=id
>
{children(cx)}
</a>
@@ -116,5 +121,5 @@ where
}
let href = use_resolved_path(cx, move || href.to_href()());
inner(cx, href, exact, state, replace, class, children)
inner(cx, href, exact, state, replace, class, id, children)
}

View File

@@ -34,10 +34,15 @@ where
if let Some(redirect_fn) = use_context::<ServerRedirectFunction>(cx) {
(redirect_fn.f)(&path);
}
// redirect on the client
let navigate = use_navigate(cx);
navigate(&path, options.unwrap_or_default())
else {
let navigate = use_navigate(cx);
leptos::request_animation_frame(move || {
if let Err(e) = navigate(&path, options.unwrap_or_default()) {
leptos::error!("<Redirect/> error: {e:?}");
}
});
}
}
/// Wrapping type for a function provided as context to allow for

View File

@@ -34,44 +34,7 @@ where
F: Fn(Scope) -> E + 'static,
P: std::fmt::Display,
{
fn inner(
cx: Scope,
children: Option<Children>,
path: String,
view: Rc<dyn Fn(Scope) -> View>,
ssr_mode: SsrMode,
) -> RouteDefinition {
let children = children
.map(|children| {
children(cx)
.as_children()
.iter()
.filter_map(|child| {
child
.as_transparent()
.and_then(|t| t.downcast_ref::<RouteDefinition>())
})
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let id = ROUTE_ID.with(|id| {
let next = id.get() + 1;
id.set(next);
next
});
RouteDefinition {
id,
path,
children,
view,
ssr_mode,
}
}
inner(
define_route(
cx,
children,
path.to_string(),
@@ -80,6 +43,91 @@ where
)
}
/// Describes a route that is guarded by a certain condition. This works the same way as
/// [`<Route/>`](Route), except that if the `condition` function evaluates to `false`, it
/// redirects to `redirect_path` instead of displaying its `view`.
#[component(transparent)]
pub fn ProtectedRoute<P, E, F, C>(
cx: Scope,
/// The path fragment that this route should match. This can be static (`users`),
/// include a parameter (`:id`) or an optional parameter (`:id?`), or match a
/// wildcard (`user/*any`).
path: P,
/// The path that will be redirected to if the condition is `false`.
redirect_path: P,
/// Condition function that returns a boolean.
condition: C,
/// View that will be exposed if the condition is `true`.
view: F,
/// The mode that this route prefers during server-side rendering. Defaults to out-of-order streaming.
#[prop(optional)]
ssr: SsrMode,
/// `children` may be empty or include nested routes.
#[prop(optional)]
children: Option<Children>,
) -> impl IntoView
where
E: IntoView,
F: Fn(Scope) -> E + 'static,
P: std::fmt::Display + 'static,
C: Fn(Scope) -> bool + 'static,
{
use crate::{Redirect, RedirectProps};
let redirect_path = redirect_path.to_string();
define_route(
cx,
children,
path.to_string(),
Rc::new(move |cx| {
if condition(cx) {
view(cx).into_view(cx)
} else {
view! { cx, <Redirect path=redirect_path.clone()/> }
.into_view(cx)
}
}),
ssr,
)
}
pub(crate) fn define_route(
cx: Scope,
children: Option<Children>,
path: String,
view: Rc<dyn Fn(Scope) -> View>,
ssr_mode: SsrMode,
) -> RouteDefinition {
let children = children
.map(|children| {
children(cx)
.as_children()
.iter()
.filter_map(|child| {
child
.as_transparent()
.and_then(|t| t.downcast_ref::<RouteDefinition>())
})
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let id = ROUTE_ID.with(|id| {
let next = id.get() + 1;
id.set(next);
next
});
RouteDefinition {
id,
path,
children,
view,
ssr_mode,
}
}
impl IntoView for RouteDefinition {
fn into_view(self, cx: Scope) -> View {
Transparent::new(self).into_view(cx)

View File

@@ -32,9 +32,17 @@ pub fn Routes(
.as_children()
.iter()
.filter_map(|child| {
child
let def = child
.as_transparent()
.and_then(|t| t.downcast_ref::<RouteDefinition>())
.and_then(|t| t.downcast_ref::<RouteDefinition>());
if def.is_none() {
warn!(
"[NOTE] The <Routes/> component should include *only* \
<Route/>or <ProtectedRoute/> components, or some \
#[component(transparent)] that returns a RouteDefinition."
);
}
def
})
.cloned()
.collect::<Vec<_>>();

View File

@@ -6,8 +6,9 @@ use proc_macro::TokenStream;
use server_fn_macro::server_macro_impl;
use syn::__private::ToTokens;
/// Declares that a function is a [server function](server_fn). This means that
/// its body will only run on the server, i.e., when the `ssr` feature is enabled.
/// Declares that a function is a [server function](https://docs.rs/server_fn/).
/// This means that its body will only run on the server, i.e., when the `ssr`
/// feature is enabled.
///
/// You can specify one, two, or three arguments to the server function:
/// 1. **Required**: A type name that will be used to identify and register the server function
@@ -41,7 +42,7 @@ use syn::__private::ToTokens;
/// - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done
/// inside the function body cant fail, the processes of serialization/deserialization and the
/// network call are fallible.
/// - **Return types must implement [Serialize](serde::Serialize).**
/// - **Return types must implement [Serialize](https://docs.rs/serde/latest/serde/trait.Serialize.html).**
/// This should be fairly obvious: we have to serialize arguments to send them to the server, and we
/// need to deserialize the result to return it to the client.
/// - **Arguments must be implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html)