From 050add21d7b3d950524e3fc3e7e236f4274495eb Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 17 Oct 2023 12:56:12 -0500 Subject: [PATCH 01/17] effect-outside-of-runtime --- packages/signals/src/effect.rs | 28 +++++++++++++++++++++------- packages/signals/src/selector.rs | 6 ++++-- packages/signals/src/signal.rs | 7 +++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/signals/src/effect.rs b/packages/signals/src/effect.rs index 9a54188d9..ad950b94b 100644 --- a/packages/signals/src/effect.rs +++ b/packages/signals/src/effect.rs @@ -1,16 +1,28 @@ use core::{self, fmt::Debug}; -use std::cell::RefCell; use std::fmt::{self, Formatter}; -use std::rc::Rc; // use dioxus_core::prelude::*; use crate::use_signal; use crate::{dependency::Dependency, CopyValue}; -#[derive(Default, Clone)] +#[derive(Copy, Clone, PartialEq)] pub(crate) struct EffectStack { - pub(crate) effects: Rc>>, + pub(crate) effects: CopyValue>, +} + +impl Default for EffectStack { + fn default() -> Self { + Self { + effects: CopyValue::new_in_scope(Vec::new(), ScopeId::ROOT), + } + } +} + +impl EffectStack { + pub(crate) fn current(&self) -> Option { + self.effects.read().last().copied() + } } pub(crate) fn get_effect_stack() -> EffectStack { @@ -57,6 +69,7 @@ pub fn use_effect_with_dependencies( pub struct Effect { pub(crate) source: ScopeId, pub(crate) callback: CopyValue>, + pub(crate) effect_stack: EffectStack, } impl Debug for Effect { @@ -67,7 +80,7 @@ impl Debug for Effect { impl Effect { pub(crate) fn current() -> Option { - get_effect_stack().effects.borrow().last().copied() + get_effect_stack().effects.read().last().copied() } /// Create a new effect. The effect will be run immediately and whenever any signal it reads changes. @@ -77,6 +90,7 @@ impl Effect { let myself = Self { source: current_scope_id().expect("in a virtual dom"), callback: CopyValue::new(Box::new(callback)), + effect_stack: get_effect_stack(), }; myself.try_run(); @@ -88,11 +102,11 @@ impl Effect { pub fn try_run(&self) { if let Some(mut callback) = self.callback.try_write() { { - get_effect_stack().effects.borrow_mut().push(*self); + self.effect_stack.effects.write().push(*self); } callback(); { - get_effect_stack().effects.borrow_mut().pop(); + self.effect_stack.effects.write().pop(); } } } diff --git a/packages/signals/src/selector.rs b/packages/signals/src/selector.rs index 91ab920c8..556c8437c 100644 --- a/packages/signals/src/selector.rs +++ b/packages/signals/src/selector.rs @@ -78,19 +78,21 @@ pub fn selector(mut f: impl FnMut() -> R + 'static) -> ReadOnlySig let effect = Effect { source: current_scope_id().expect("in a virtual dom"), callback: CopyValue::invalid(), + effect_stack: get_effect_stack(), }; { - get_effect_stack().effects.borrow_mut().push(effect); + get_effect_stack().effects.write().push(effect); } state.inner.value.set(SignalData { subscribers: Default::default(), effect_subscribers: Default::default(), update_any: schedule_update_any().expect("in a virtual dom"), value: f(), + effect_stack: get_effect_stack(), }); { - get_effect_stack().effects.borrow_mut().pop(); + get_effect_stack().effects.write().pop(); } effect.callback.value.set(Box::new(move || { diff --git a/packages/signals/src/signal.rs b/packages/signals/src/signal.rs index c35b04031..9b11572b4 100644 --- a/packages/signals/src/signal.rs +++ b/packages/signals/src/signal.rs @@ -11,7 +11,7 @@ use dioxus_core::{ ScopeId, ScopeState, }; -use crate::{CopyValue, Effect}; +use crate::{get_effect_stack, CopyValue, Effect, EffectStack}; /// Creates a new Signal. Signals are a Copy state management solution with automatic dependency tracking. /// @@ -82,6 +82,7 @@ pub(crate) struct SignalData { pub(crate) subscribers: Rc>>, pub(crate) effect_subscribers: Rc>>, pub(crate) update_any: Arc, + pub(crate) effect_stack: EffectStack, pub(crate) value: T, } @@ -144,6 +145,7 @@ impl Signal { effect_subscribers: Default::default(), update_any: schedule_update_any().expect("in a virtual dom"), value, + effect_stack: get_effect_stack(), }), } } @@ -157,6 +159,7 @@ impl Signal { effect_subscribers: Default::default(), update_any: schedule_update_any().expect("in a virtual dom"), value, + effect_stack: get_effect_stack(), }, owner, ), @@ -172,7 +175,7 @@ impl Signal { /// If the signal has been dropped, this will panic. pub fn read(&self) -> Ref { let inner = self.inner.read(); - if let Some(effect) = Effect::current() { + if let Some(effect) = inner.effect_stack.current() { let mut effect_subscribers = inner.effect_subscribers.borrow_mut(); if !effect_subscribers.contains(&effect) { effect_subscribers.push(effect); From ce86aabf262d53cbf8cbf0a5cbb74630725b8970 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Tue, 17 Oct 2023 13:02:51 -0500 Subject: [PATCH 02/17] fix clippy --- packages/desktop/src/protocol.rs | 2 +- packages/signals/src/effect.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index 53652f82f..2f660b7a2 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -153,7 +153,7 @@ fn get_asset_root() -> Option { /// Get the mime type from a path-like string fn get_mime_from_path(trimmed: &Path) -> Result<&'static str> { - if trimmed.ends_with(".svg") { + if trimmed.extension().is_some_and(|ext| ext == "svg") { return Ok("image/svg+xml"); } diff --git a/packages/signals/src/effect.rs b/packages/signals/src/effect.rs index ad950b94b..307ee98ab 100644 --- a/packages/signals/src/effect.rs +++ b/packages/signals/src/effect.rs @@ -30,7 +30,7 @@ pub(crate) fn get_effect_stack() -> EffectStack { Some(rt) => rt, None => { let store = EffectStack::default(); - provide_root_context(store.clone()); + provide_root_context(store); store } } From ad9c59c2b4c49941d512aa126c07d96a9f7a3281 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Thu, 19 Oct 2023 13:23:58 -0500 Subject: [PATCH 03/17] add an readable error when you name a prop key --- packages/core-macro/src/props/mod.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core-macro/src/props/mod.rs b/packages/core-macro/src/props/mod.rs index c1931e497..1fca9baf7 100644 --- a/packages/core-macro/src/props/mod.rs +++ b/packages/core-macro/src/props/mod.rs @@ -467,7 +467,7 @@ fn type_from_inside_option(ty: &syn::Type, check_option_name: bool) -> Option<&s mod struct_info { use proc_macro2::TokenStream; - use quote::quote; + use quote::{quote, quote_spanned}; use syn::parse::Error; use syn::punctuated::Punctuated; use syn::Expr; @@ -701,6 +701,14 @@ Finally, call `.build()` to create the instance of `{name}`. } pub fn field_impl(&self, field: &FieldInfo) -> Result { + let FieldInfo { + name: field_name, + ty: field_type, + .. + } = field; + if field_name.to_string() == "key" { + return Err(Error::new_spanned(field_name, "Naming a prop `key` is not allowed because the name can conflict with the built in key attribute. See https://dioxuslabs.com/learn/0.4/reference/dynamic_rendering#rendering-lists for more information about keys")); + } let StructInfo { ref builder_name, .. } = *self; @@ -715,11 +723,6 @@ Finally, call `.build()` to create the instance of `{name}`. }); let reconstructing = self.included_fields().map(|f| f.name); - let FieldInfo { - name: field_name, - ty: field_type, - .. - } = field; let mut ty_generics: Vec = self .generics .params From 42eb4b5098c3980171e4690d482d2e7a8021a3ef Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Thu, 19 Oct 2023 15:48:06 -0500 Subject: [PATCH 04/17] fix clippy --- packages/core-macro/src/props/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-macro/src/props/mod.rs b/packages/core-macro/src/props/mod.rs index 1fca9baf7..605983afc 100644 --- a/packages/core-macro/src/props/mod.rs +++ b/packages/core-macro/src/props/mod.rs @@ -467,7 +467,7 @@ fn type_from_inside_option(ty: &syn::Type, check_option_name: bool) -> Option<&s mod struct_info { use proc_macro2::TokenStream; - use quote::{quote, quote_spanned}; + use quote::quote; use syn::parse::Error; use syn::punctuated::Punctuated; use syn::Expr; @@ -706,7 +706,7 @@ Finally, call `.build()` to create the instance of `{name}`. ty: field_type, .. } = field; - if field_name.to_string() == "key" { + if *field_name == "key" { return Err(Error::new_spanned(field_name, "Naming a prop `key` is not allowed because the name can conflict with the built in key attribute. See https://dioxuslabs.com/learn/0.4/reference/dynamic_rendering#rendering-lists for more information about keys")); } let StructInfo { From e41278af4539f3a46ac8bd9cd9940ef0b56cfeec Mon Sep 17 00:00:00 2001 From: Leonard Date: Fri, 20 Oct 2023 23:10:06 +0200 Subject: [PATCH 05/17] Add link to website section on contributing to root README (#1559) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5b6a99ad3..d1ec98019 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ So... Dioxus is great, but why won't it work for me? ## Contributing +- Check out the website [section on contributing](https://dioxuslabs.com/learn/0.4/contributing). - Report issues on our [issue tracker](https://github.com/dioxuslabs/dioxus/issues). - Join the discord and ask questions! From 74952179db25e37ae6f9957bc7109ee276889d31 Mon Sep 17 00:00:00 2001 From: Leonard Date: Sun, 22 Oct 2023 16:28:17 +0200 Subject: [PATCH 06/17] Append prop documentation to function when using `#[inline_props]` or `#[component]` (#1563) * Do the stuff * Add deprecated warning and attribute list Also move out methods that didn't take self as a parameter from `InlinePropsDeserializer` --- .../inline_props.rs | 420 ++++++++++++------ 1 file changed, 276 insertions(+), 144 deletions(-) diff --git a/packages/core-macro/src/component_body_deserializers/inline_props.rs b/packages/core-macro/src/component_body_deserializers/inline_props.rs index 8e4581c19..a6e0a07d2 100644 --- a/packages/core-macro/src/component_body_deserializers/inline_props.rs +++ b/packages/core-macro/src/component_body_deserializers/inline_props.rs @@ -30,166 +30,298 @@ impl ToTokens for InlinePropsDeserializerOutput { impl DeserializerArgs for InlinePropsDeserializerArgs { fn to_output(&self, component_body: &ComponentBody) -> Result { Ok(InlinePropsDeserializerOutput { - comp_fn: Self::get_function(component_body), - props_struct: Self::get_props_struct(component_body), + comp_fn: get_function(component_body), + props_struct: get_props_struct(component_body), }) } } -impl InlinePropsDeserializerArgs { - fn get_props_struct(component_body: &ComponentBody) -> ItemStruct { - let ComponentBody { item_fn, .. } = component_body; - let ItemFn { vis, sig, .. } = item_fn; - let Signature { - inputs, - ident: fn_ident, - generics, - .. - } = sig; +fn get_props_struct(component_body: &ComponentBody) -> ItemStruct { + let ComponentBody { item_fn, .. } = component_body; + let ItemFn { vis, sig, .. } = item_fn; + let Signature { + inputs, + ident: fn_ident, + generics, + .. + } = sig; - // Skip first arg since that's the context - let struct_fields = inputs.iter().skip(1).map(move |f| { - match f { - FnArg::Receiver(_) => unreachable!(), // Unreachable because of ComponentBody parsing - FnArg::Typed(pt) => { - let arg_pat = &pt.pat; // Pattern (identifier) - let arg_colon = &pt.colon_token; - let arg_ty = &pt.ty; // Type - let arg_attrs = &pt.attrs; // Attributes + // Skip first arg since that's the context + let struct_fields = inputs.iter().skip(1).map(move |f| { + match f { + FnArg::Receiver(_) => unreachable!(), // Unreachable because of ComponentBody parsing + FnArg::Typed(pt) => { + let arg_pat = &pt.pat; // Pattern (identifier) + let arg_colon = &pt.colon_token; + let arg_ty = &pt.ty; // Type + let arg_attrs = &pt.attrs; // Attributes - quote! { - #(#arg_attrs) - * - #vis #arg_pat #arg_colon #arg_ty - } + quote! { + #(#arg_attrs) + * + #vis #arg_pat #arg_colon #arg_ty } } - }); - - let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); - - let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { - Some(lt) - } else { - None - }; - - let struct_attrs = if first_lifetime.is_some() { - quote! { #[derive(Props)] } - } else { - quote! { #[derive(Props, PartialEq)] } - }; - - let struct_generics = if first_lifetime.is_some() { - let struct_generics: Punctuated = component_body - .item_fn - .sig - .generics - .params - .iter() - .map(|it| match it { - GenericParam::Type(tp) => { - let mut tp = tp.clone(); - tp.bounds.push(parse_quote!( 'a )); - - GenericParam::Type(tp) - } - _ => it.clone(), - }) - .collect(); - - quote! { <#struct_generics> } - } else { - quote! { #generics } - }; - - parse_quote! { - #struct_attrs - #[allow(non_camel_case_types)] - #vis struct #struct_ident #struct_generics - { - #(#struct_fields),* - } } - } + }); - fn get_function(component_body: &ComponentBody) -> ItemFn { - let ComponentBody { - item_fn, - cx_pat_type, - .. - } = component_body; - let ItemFn { - attrs: fn_attrs, - vis, - sig, - block: fn_block, - } = item_fn; - let Signature { - inputs, - ident: fn_ident, - generics, - output: fn_output, - asyncness, - .. - } = sig; - let Generics { where_clause, .. } = generics; + let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); - let cx_pat = &cx_pat_type.pat; - let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); + let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { + Some(lt) + } else { + None + }; - // Skip first arg since that's the context - let struct_field_names = inputs.iter().skip(1).filter_map(|f| match f { - FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters. - FnArg::Typed(t) => Some(&t.pat), - }); + let struct_attrs = if first_lifetime.is_some() { + quote! { #[derive(Props)] } + } else { + quote! { #[derive(Props, PartialEq)] } + }; - let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { - Some(lt) - } else { - None - }; + let struct_generics = if first_lifetime.is_some() { + let struct_generics: Punctuated = component_body + .item_fn + .sig + .generics + .params + .iter() + .map(|it| match it { + GenericParam::Type(tp) => { + let mut tp = tp.clone(); + tp.bounds.push(parse_quote!( 'a )); - let (scope_lifetime, fn_generics) = if let Some(lt) = first_lifetime { - (quote! { #lt, }, generics.clone()) - } else { - let lifetime: LifetimeParam = parse_quote! { 'a }; + GenericParam::Type(tp) + } + _ => it.clone(), + }) + .collect(); - let mut fn_generics = generics.clone(); - fn_generics - .params - .insert(0, GenericParam::Lifetime(lifetime.clone())); + quote! { <#struct_generics> } + } else { + quote! { #generics } + }; - (quote! { #lifetime, }, fn_generics) - }; - - let generics_no_bounds = { - let mut generics = generics.clone(); - generics.params = generics - .params - .iter() - .map(|it| match it { - GenericParam::Type(tp) => { - let mut tp = tp.clone(); - tp.bounds.clear(); - - GenericParam::Type(tp) - } - _ => it.clone(), - }) - .collect(); - - generics - }; - - parse_quote! { - #(#fn_attrs)* - #asyncness #vis fn #fn_ident #fn_generics (#cx_pat: Scope<#scope_lifetime #struct_ident #generics_no_bounds>) #fn_output - #where_clause - { - let #struct_ident { #(#struct_field_names),* } = &#cx_pat.props; - #fn_block - } + parse_quote! { + #struct_attrs + #[allow(non_camel_case_types)] + #vis struct #struct_ident #struct_generics + { + #(#struct_fields),* } } } + +fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec { + if inputs.len() <= 1 { + return Vec::new(); + } + + let arg_docs = inputs + .iter() + .filter_map(|f| match f { + FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters. + FnArg::Typed(pt) => { + let arg_doc = pt + .attrs + .iter() + .filter_map(|attr| { + // TODO: Error reporting + // Check if the path of the attribute is "doc" + if !is_attr_doc(attr) { + return None; + }; + + let Meta::NameValue(meta_name_value) = &attr.meta else { + return None; + }; + + let Expr::Lit(doc_lit) = &meta_name_value.value else { + return None; + }; + + let Lit::Str(doc_lit_str) = &doc_lit.lit else { + return None; + }; + + Some(doc_lit_str.value()) + }) + .fold(String::new(), |mut doc, next_doc_line| { + doc.push('\n'); + doc.push_str(&next_doc_line); + doc + }); + + Some(( + pt.pat.to_token_stream().to_string(), + pt.ty.to_token_stream().to_string(), + pt.attrs.iter().filter(|a| !is_attr_doc(a)), + arg_doc, + )) + } + }) + .collect::>(); + + let mut props_docs = Vec::with_capacity(5); + let props_def_link = fn_ident.to_string() + "Props"; + let header = + format!("# Props\n*For details, see the [props struct definition]({props_def_link}).*"); + + props_docs.push(parse_quote! { + #[doc = #header] + }); + + for (arg_name, arg_type, attrs, input_arg_doc) in arg_docs { + let input_arg_doc = + keep_up_to_two_consecutive_chars(input_arg_doc.trim_matches('\n'), '\n') + .replace('\n', "
"); + let prop_def_link = format!("{props_def_link}::{arg_name}"); + let mut attribute_list = String::new(); + let mut is_deprecated = false; + + for attr in attrs { + if attr.path() == &parse_quote!(deprecated) { + is_deprecated = true; + continue; + } + + attribute_list.push('`'); + attribute_list.push_str("e!(#attr).into_token_stream().to_string()); + attribute_list.push_str("`, "); + } + + let mut arg_doc = format!("- [`{arg_name}`]({prop_def_link})"); + + if is_deprecated { + arg_doc.push_str(" πŸ‘Ž Deprecated"); + } + + arg_doc.push_str(&format!("
Type: `{arg_type}`")); + + if !attribute_list.is_empty() { + // Truncate the last `, ` + attribute_list.truncate(attribute_list.len() - 2); + arg_doc.push_str(&format!("
Attributes: {attribute_list}")); + } + + arg_doc.push_str(&format!("
{input_arg_doc}")); + + props_docs.push(parse_quote! { + #[doc = #arg_doc] + }); + } + + props_docs +} + +fn get_function(component_body: &ComponentBody) -> ItemFn { + let ComponentBody { + item_fn, + cx_pat_type, + .. + } = component_body; + let ItemFn { + attrs: fn_attrs, + vis, + sig, + block: fn_block, + } = item_fn; + let Signature { + inputs, + ident: fn_ident, + generics, + output: fn_output, + asyncness, + .. + } = sig; + let Generics { where_clause, .. } = generics; + + let cx_pat = &cx_pat_type.pat; + let struct_ident = Ident::new(&format!("{fn_ident}Props"), fn_ident.span()); + + // Skip first arg since that's the context + let struct_field_names = inputs.iter().skip(1).filter_map(|f| match f { + FnArg::Receiver(_) => unreachable!(), // ComponentBody prohibits receiver parameters. + FnArg::Typed(pt) => Some(&pt.pat), + }); + + let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() { + Some(lt) + } else { + None + }; + + let (scope_lifetime, fn_generics) = if let Some(lt) = first_lifetime { + (quote! { #lt, }, generics.clone()) + } else { + let lifetime: LifetimeParam = parse_quote! { 'a }; + + let mut fn_generics = generics.clone(); + fn_generics + .params + .insert(0, GenericParam::Lifetime(lifetime.clone())); + + (quote! { #lifetime, }, fn_generics) + }; + + let generics_no_bounds = { + let mut generics = generics.clone(); + generics.params = generics + .params + .iter() + .map(|it| match it { + GenericParam::Type(tp) => { + let mut tp = tp.clone(); + tp.bounds.clear(); + + GenericParam::Type(tp) + } + _ => it.clone(), + }) + .collect(); + + generics + }; + + let props_docs = get_props_docs(fn_ident, inputs.iter().skip(1).collect()); + + parse_quote! { + #(#fn_attrs)* + #(#props_docs)* + #asyncness #vis fn #fn_ident #fn_generics (#cx_pat: Scope<#scope_lifetime #struct_ident #generics_no_bounds>) #fn_output + #where_clause + { + let #struct_ident { #(#struct_field_names),* } = &#cx_pat.props; + #fn_block + } + } +} + +/// Checks if the attribute is a `#[doc]` attribute. +fn is_attr_doc(attr: &Attribute) -> bool { + attr.path() == &parse_quote!(doc) +} + +fn keep_up_to_two_consecutive_chars(input: &str, target_char: char) -> String { + let mut output = String::new(); + let mut prev_char: Option = None; + let mut consecutive_count = 0; + + for c in input.chars() { + match prev_char { + Some(prev) if c == target_char && prev == target_char => { + if consecutive_count < 2 { + output.push(c); + consecutive_count += 1; + } + } + _ => { + output.push(c); + prev_char = Some(c); + consecutive_count = 1; + } + } + } + + output +} From cea9563e259e51f45de40d8ac1078024aef29be6 Mon Sep 17 00:00:00 2001 From: Leonard Date: Mon, 23 Oct 2023 15:41:26 +0200 Subject: [PATCH 07/17] Improve prop docs (#1565) * Do the stuff * More sanitizing + fix warnings and format --- packages/core-macro/Cargo.toml | 1 + .../inline_props.rs | 80 ++++++----- packages/core-macro/src/lib.rs | 1 + packages/core-macro/src/utils.rs | 129 ++++++++++++++++++ 4 files changed, 177 insertions(+), 34 deletions(-) create mode 100644 packages/core-macro/src/utils.rs diff --git a/packages/core-macro/Cargo.toml b/packages/core-macro/Cargo.toml index d928d5fec..1898d8bd1 100644 --- a/packages/core-macro/Cargo.toml +++ b/packages/core-macro/Cargo.toml @@ -19,6 +19,7 @@ syn = { version = "2.0", features = ["full", "extra-traits"] } dioxus-rsx = { workspace = true } dioxus-core = { workspace = true } constcat = "0.3.0" +prettyplease = "0.2.15" # testing [dev-dependencies] diff --git a/packages/core-macro/src/component_body_deserializers/inline_props.rs b/packages/core-macro/src/component_body_deserializers/inline_props.rs index a6e0a07d2..670a14d88 100644 --- a/packages/core-macro/src/component_body_deserializers/inline_props.rs +++ b/packages/core-macro/src/component_body_deserializers/inline_props.rs @@ -153,9 +153,20 @@ fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec { }); Some(( - pt.pat.to_token_stream().to_string(), - pt.ty.to_token_stream().to_string(), - pt.attrs.iter().filter(|a| !is_attr_doc(a)), + &pt.pat, + &pt.ty, + pt.attrs.iter().find_map(|attr| { + if attr.path() != &parse_quote!(deprecated) { + return None; + } + + let res = crate::utils::DeprecatedAttribute::from_meta(&attr.meta); + + match res { + Err(e) => panic!("{}", e.to_string()), + Ok(v) => Some(v), + } + }), arg_doc, )) } @@ -171,40 +182,37 @@ fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec { #[doc = #header] }); - for (arg_name, arg_type, attrs, input_arg_doc) in arg_docs { - let input_arg_doc = - keep_up_to_two_consecutive_chars(input_arg_doc.trim_matches('\n'), '\n') - .replace('\n', "
"); - let prop_def_link = format!("{props_def_link}::{arg_name}"); - let mut attribute_list = String::new(); - let mut is_deprecated = false; + for (arg_name, arg_type, deprecation, input_arg_doc) in arg_docs { + let arg_name = arg_name.into_token_stream().to_string(); + let arg_type = crate::utils::format_type_string(arg_type); - for attr in attrs { - if attr.path() == &parse_quote!(deprecated) { - is_deprecated = true; - continue; + let input_arg_doc = + keep_up_to_n_consecutive_chars(input_arg_doc.trim(), 2, '\n').replace('\n', "
"); + let prop_def_link = format!("{props_def_link}::{arg_name}"); + let mut arg_doc = format!("- [`{arg_name}`]({prop_def_link}) : `{arg_type}`"); + + if let Some(deprecation) = deprecation { + arg_doc.push_str("
πŸ‘Ž Deprecated"); + + if let Some(since) = deprecation.since { + arg_doc.push_str(&format!(" since {since}")); } - attribute_list.push('`'); - attribute_list.push_str("e!(#attr).into_token_stream().to_string()); - attribute_list.push_str("`, "); + if let Some(note) = deprecation.note { + let note = keep_up_to_n_consecutive_chars(¬e, 1, '\n').replace('\n', " "); + let note = keep_up_to_n_consecutive_chars(¬e, 1, '\t').replace('\t', " "); + + arg_doc.push_str(&format!(": {note}")); + } + + if !input_arg_doc.is_empty() { + arg_doc.push_str("
"); + } + } else { + arg_doc.push_str("
"); } - let mut arg_doc = format!("- [`{arg_name}`]({prop_def_link})"); - - if is_deprecated { - arg_doc.push_str(" πŸ‘Ž Deprecated"); - } - - arg_doc.push_str(&format!("
Type: `{arg_type}`")); - - if !attribute_list.is_empty() { - // Truncate the last `, ` - attribute_list.truncate(attribute_list.len() - 2); - arg_doc.push_str(&format!("
Attributes: {attribute_list}")); - } - - arg_doc.push_str(&format!("
{input_arg_doc}")); + arg_doc.push_str(&input_arg_doc); props_docs.push(parse_quote! { #[doc = #arg_doc] @@ -302,7 +310,11 @@ fn is_attr_doc(attr: &Attribute) -> bool { attr.path() == &parse_quote!(doc) } -fn keep_up_to_two_consecutive_chars(input: &str, target_char: char) -> String { +fn keep_up_to_n_consecutive_chars( + input: &str, + n_of_consecutive_chars_allowed: usize, + target_char: char, +) -> String { let mut output = String::new(); let mut prev_char: Option = None; let mut consecutive_count = 0; @@ -310,7 +322,7 @@ fn keep_up_to_two_consecutive_chars(input: &str, target_char: char) -> String { for c in input.chars() { match prev_char { Some(prev) if c == target_char && prev == target_char => { - if consecutive_count < 2 { + if consecutive_count < n_of_consecutive_chars_allowed { output.push(c); consecutive_count += 1; } diff --git a/packages/core-macro/src/lib.rs b/packages/core-macro/src/lib.rs index 477c05d76..4686bc797 100644 --- a/packages/core-macro/src/lib.rs +++ b/packages/core-macro/src/lib.rs @@ -12,6 +12,7 @@ use syn::{parse_macro_input, Path, Token}; mod component_body; mod component_body_deserializers; mod props; +mod utils; // mod rsx; use crate::component_body::ComponentBody; diff --git a/packages/core-macro/src/utils.rs b/packages/core-macro/src/utils.rs new file mode 100644 index 000000000..7e066f773 --- /dev/null +++ b/packages/core-macro/src/utils.rs @@ -0,0 +1,129 @@ +use quote::ToTokens; +use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; +use syn::{parse_quote, Expr, Lit, Meta, Token, Type}; + +const FORMATTED_TYPE_START: &str = "static TY_AFTER_HERE:"; +const FORMATTED_TYPE_END: &str = "= todo!();"; + +/// Attempts to convert the given literal to a string. +/// Converts ints and floats to their base 10 counterparts. +/// +/// Returns `None` if the literal is [`Lit::Verbatim`] or if the literal is [`Lit::ByteStr`] +/// and the byte string could not be converted to UTF-8. +pub fn lit_to_string(lit: Lit) -> Option { + match lit { + Lit::Str(l) => Some(l.value()), + Lit::ByteStr(l) => String::from_utf8(l.value()).ok(), + Lit::Byte(l) => Some(String::from(l.value() as char)), + Lit::Char(l) => Some(l.value().to_string()), + Lit::Int(l) => Some(l.base10_digits().to_string()), + Lit::Float(l) => Some(l.base10_digits().to_string()), + Lit::Bool(l) => Some(l.value().to_string()), + Lit::Verbatim(_) => None, + _ => None, + } +} + +pub fn format_type_string(ty: &Type) -> String { + let ty_unformatted = ty.into_token_stream().to_string(); + let ty_unformatted = ty_unformatted.trim(); + + // This should always be valid syntax. + // Not Rust code, but syntax, which is the only thing that `syn` cares about. + let Ok(file_unformatted) = syn::parse_file(&format!( + "{FORMATTED_TYPE_START}{ty_unformatted}{FORMATTED_TYPE_END}" + )) else { + return ty_unformatted.to_string(); + }; + + let file_formatted = prettyplease::unparse(&file_unformatted); + + let file_trimmed = file_formatted.trim(); + let start_removed = file_trimmed.trim_start_matches(FORMATTED_TYPE_START); + let end_removed = start_removed.trim_end_matches(FORMATTED_TYPE_END); + let ty_formatted = end_removed.trim(); + + ty_formatted.to_string() +} + +/// Represents the `#[deprecated]` attribute. +/// +/// You can use the [`DeprecatedAttribute::from_meta`] function to try to parse an attribute to this struct. +#[derive(Default)] +pub struct DeprecatedAttribute { + pub since: Option, + pub note: Option, +} + +impl DeprecatedAttribute { + /// Returns `None` if the given attribute was not a valid form of the `#[deprecated]` attribute. + pub fn from_meta(meta: &Meta) -> syn::Result { + if meta.path() != &parse_quote!(deprecated) { + return Err(syn::Error::new( + meta.span(), + "attribute path is not `deprecated`", + )); + } + + match &meta { + Meta::Path(_) => Ok(Self::default()), + Meta::NameValue(name_value) => { + let Expr::Lit(expr_lit) = &name_value.value else { + return Err(syn::Error::new( + name_value.span(), + "literal in `deprecated` value must be a string", + )); + }; + + Ok(Self { + since: None, + note: lit_to_string(expr_lit.lit.clone()).map(|s| s.trim().to_string()), + }) + } + Meta::List(list) => { + let parsed = list.parse_args::()?; + + Ok(Self { + since: parsed.since.map(|s| s.trim().to_string()), + note: parsed.note.map(|s| s.trim().to_string()), + }) + } + } + } +} + +mod kw { + use syn::custom_keyword; + custom_keyword!(since); + custom_keyword!(note); +} + +struct DeprecatedAttributeArgsParser { + since: Option, + note: Option, +} + +impl Parse for DeprecatedAttributeArgsParser { + fn parse(input: ParseStream) -> syn::Result { + let mut since: Option = None; + let mut note: Option = None; + + if input.peek(kw::since) { + input.parse::()?; + input.parse::()?; + + since = lit_to_string(input.parse()?); + } + + if input.peek(Token![,]) && input.peek2(kw::note) { + input.parse::()?; + input.parse::()?; + input.parse::()?; + + note = lit_to_string(input.parse()?); + } + + Ok(Self { since, note }) + } +} From d9b32afd64615e8c2a7eb247d20533286c38aaaa Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Mon, 23 Oct 2023 12:45:10 -0500 Subject: [PATCH 08/17] add error message when overlapping fullstack features are enabled --- packages/fullstack/src/launch.rs | 11 +++++++++-- packages/fullstack/src/lib.rs | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/fullstack/src/launch.rs b/packages/fullstack/src/launch.rs index c982a1c90..bd145f363 100644 --- a/packages/fullstack/src/launch.rs +++ b/packages/fullstack/src/launch.rs @@ -121,8 +121,15 @@ impl Date: Tue, 24 Oct 2023 00:10:56 +0200 Subject: [PATCH 09/17] Fix --- .../component_body_deserializers/inline_props.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core-macro/src/component_body_deserializers/inline_props.rs b/packages/core-macro/src/component_body_deserializers/inline_props.rs index 670a14d88..d51132623 100644 --- a/packages/core-macro/src/component_body_deserializers/inline_props.rs +++ b/packages/core-macro/src/component_body_deserializers/inline_props.rs @@ -186,13 +186,13 @@ fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec { let arg_name = arg_name.into_token_stream().to_string(); let arg_type = crate::utils::format_type_string(arg_type); - let input_arg_doc = - keep_up_to_n_consecutive_chars(input_arg_doc.trim(), 2, '\n').replace('\n', "
"); + let input_arg_doc = keep_up_to_n_consecutive_chars(input_arg_doc.trim(), 2, '\n') + .replace("\n\n", "

"); let prop_def_link = format!("{props_def_link}::{arg_name}"); let mut arg_doc = format!("- [`{arg_name}`]({prop_def_link}) : `{arg_type}`"); if let Some(deprecation) = deprecation { - arg_doc.push_str("
πŸ‘Ž Deprecated"); + arg_doc.push_str("

πŸ‘Ž Deprecated"); if let Some(since) = deprecation.since { arg_doc.push_str(&format!(" since {since}")); @@ -205,14 +205,16 @@ fn get_props_docs(fn_ident: &Ident, inputs: Vec<&FnArg>) -> Vec { arg_doc.push_str(&format!(": {note}")); } + arg_doc.push_str("

"); + if !input_arg_doc.is_empty() { arg_doc.push_str("
"); } - } else { - arg_doc.push_str("
"); } - arg_doc.push_str(&input_arg_doc); + if !input_arg_doc.is_empty() { + arg_doc.push_str(&format!("

{input_arg_doc}

")); + } props_docs.push(parse_quote! { #[doc = #arg_doc] From 85839abe5668a34c87a2d325ef2dd5a115ec7978 Mon Sep 17 00:00:00 2001 From: Leonard Date: Tue, 24 Oct 2023 01:35:59 +0200 Subject: [PATCH 10/17] Fix random clippy warning --- packages/desktop/src/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index 53652f82f..3d51e3ac1 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -153,7 +153,7 @@ fn get_asset_root() -> Option { /// Get the mime type from a path-like string fn get_mime_from_path(trimmed: &Path) -> Result<&'static str> { - if trimmed.ends_with(".svg") { + if trimmed.extension().is_some_and(|ext| ext == ".svg") { return Ok("image/svg+xml"); } From 613f2f9acaff5295395c7ce001cb41561203a6b6 Mon Sep 17 00:00:00 2001 From: Leonard Date: Tue, 24 Oct 2023 01:41:46 +0200 Subject: [PATCH 11/17] Revert "Fix random clippy warning" This reverts commit 85839abe5668a34c87a2d325ef2dd5a115ec7978. --- packages/desktop/src/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index 3d51e3ac1..53652f82f 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -153,7 +153,7 @@ fn get_asset_root() -> Option { /// Get the mime type from a path-like string fn get_mime_from_path(trimmed: &Path) -> Result<&'static str> { - if trimmed.extension().is_some_and(|ext| ext == ".svg") { + if trimmed.ends_with(".svg") { return Ok("image/svg+xml"); } From b25fada776c61013217540c0a3cd6f4294563c81 Mon Sep 17 00:00:00 2001 From: Stephen Andary Date: Wed, 25 Oct 2023 16:36:38 -0400 Subject: [PATCH 12/17] Add Newtype so that Rust-url can be IntoRoutable (#1579) * add newtype so that rust-url can IntoRoutable * add doc line * implement From directly --------- Co-authored-by: Evan Almloff --- packages/router/src/components/link.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/router/src/components/link.rs b/packages/router/src/components/link.rs index 787f9cdc9..639d99ae7 100644 --- a/packages/router/src/components/link.rs +++ b/packages/router/src/components/link.rs @@ -11,6 +11,8 @@ use crate::navigation::NavigationTarget; use crate::prelude::Routable; use crate::utils::use_router_internal::use_router_internal; +use url::Url; + /// Something that can be converted into a [`NavigationTarget`]. #[derive(Clone)] pub enum IntoRoutable { @@ -53,6 +55,18 @@ impl From<&str> for IntoRoutable { } } +impl From for IntoRoutable { + fn from(url: Url) -> Self { + IntoRoutable::FromStr(url.to_string()) + } +} + +impl From<&Url> for IntoRoutable { + fn from(url: &Url) -> Self { + IntoRoutable::FromStr(url.to_string()) + } +} + /// The properties for a [`Link`]. #[derive(Props)] pub struct LinkProps<'a> { From f5bc1a9856c0287f14526830df6c83d98f1116e8 Mon Sep 17 00:00:00 2001 From: Emil Boman Date: Thu, 26 Oct 2023 21:19:51 +0200 Subject: [PATCH 13/17] Implement LiveView Router Integration (#1505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix GoForwardButton calling can_go_back instead of can_go_forward * Add first draft of LiveviewHistory * Add external URL redirect * Lock evaluator channel outside loop * Add liveview to router examples * fixup! Add liveview to router examples * Communicate with liveview server on page load * Add PopState event to Liveview routing * Call updater callback from liveview history * Add rudimentary PopState functionality to liveview router. * Fix linter errors * Refactor * Fix navigator external redirection not working. * Add go back and go forward buttons to router examples * Finish functionality for timeline stack in liveview router * Add docs to LiveviewHistory * Replace Liveview history context attachment with constructor that takes context * Fix go forward/backward history/future shuffle * Support history across entire liveview session, if contiguous page jumps. * Remove unnecessary bounds * Add query and hash to location string * Run rustfmt * fix: Update server function docs link (#1489) * liveview: Add `interpreter_glue_relative_uri (#1481) * liveview: Add `interpreter_glue_relative_uri` By utilizing `window.location.host` in the client-side JavaScript, we can easily derive the WebSocket URI from a relative path URI. This approach obviates the need for host address retrieval on the server side, unlike the method of serving glue code in liveview using `interpreter_glue`. * liveview: Merge `.._relative_url` functionality - Merged `.._relative_url` to current API `interpreter_glue`. - Edit axum example to work with new feature. * liveview: Fix clippy warning * Rename modules to use snake_case (#1498) * Change Scope into &ScopeState * Move synchronization of state into router and make it opt-in --------- Co-authored-by: Marc EspΓ­n Co-authored-by: Seungwoo Kang Co-authored-by: Leonard Co-authored-by: Evan Almloff --- packages/router/Cargo.toml | 5 + packages/router/examples/simple_routes.rs | 72 ++- .../router/src/components/history_buttons.rs | 2 +- packages/router/src/contexts/router.rs | 2 +- packages/router/src/history/liveview.rs | 441 ++++++++++++++++++ packages/router/src/history/mod.rs | 5 + 6 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 packages/router/src/history/liveview.rs diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index 80923d539..da9d3fe85 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -19,6 +19,7 @@ thiserror = { workspace = true } futures-util = { workspace = true } urlencoding = "2.1.3" serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1.0.91", optional = true } url = "2.3.1" wasm-bindgen = { workspace = true, optional = true } web-sys = { version = "0.3.60", optional = true, features = [ @@ -26,18 +27,22 @@ web-sys = { version = "0.3.60", optional = true, features = [ ] } js-sys = { version = "0.3.63", optional = true } gloo-utils = { version = "0.1.6", optional = true } +dioxus-liveview = { workspace = true, optional = true } dioxus-ssr = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"], optional = true } [features] default = ["web"] ssr = ["dioxus-ssr", "tokio"] +liveview = ["dioxus-liveview", "tokio", "dep:serde", "serde_json"] wasm_test = [] serde = ["dep:serde", "gloo-utils/serde"] web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] [dev-dependencies] +axum = { version = "0.6.1", features = ["ws"] } dioxus = { path = "../dioxus" } +dioxus-liveview = { workspace = true, features = ["axum"] } dioxus-ssr = { path = "../ssr" } criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } diff --git a/packages/router/examples/simple_routes.rs b/packages/router/examples/simple_routes.rs index 3634f7df3..aac1e8658 100644 --- a/packages/router/examples/simple_routes.rs +++ b/packages/router/examples/simple_routes.rs @@ -2,6 +2,47 @@ use dioxus::prelude::*; use dioxus_router::prelude::*; use std::str::FromStr; +#[cfg(feature = "liveview")] +#[tokio::main] +async fn main() { + use axum::{extract::ws::WebSocketUpgrade, response::Html, routing::get, Router}; + + let listen_address: std::net::SocketAddr = ([127, 0, 0, 1], 3030).into(); + let view = dioxus_liveview::LiveViewPool::new(); + let app = Router::new() + .fallback(get(move || async move { + Html(format!( + r#" + + + +
+ {glue} + + "#, + glue = dioxus_liveview::interpreter_glue(&format!("ws://{listen_address}/ws")) + )) + })) + .route( + "/ws", + get(move |ws: WebSocketUpgrade| async move { + ws.on_upgrade(move |socket| async move { + _ = view + .launch(dioxus_liveview::axum_socket(socket), Root) + .await; + }) + }), + ); + + println!("Listening on http://{listen_address}"); + + axum::Server::bind(&listen_address.to_string().parse().unwrap()) + .serve(app.into_make_service()) + .await + .unwrap(); +} + +#[cfg(not(feature = "liveview"))] fn main() { #[cfg(not(target_arch = "wasm32"))] dioxus_desktop::launch(Root); @@ -10,21 +51,26 @@ fn main() { dioxus_web::launch(root); } +#[cfg(feature = "liveview")] #[component] fn Root(cx: Scope) -> Element { - render! { - Router:: {} - } + let history = LiveviewHistory::new(cx); + render! { Router:: { + config: || RouterConfig::default().history(history), + } } +} + +#[cfg(not(feature = "liveview"))] +#[component] +fn Root(cx: Scope) -> Element { + render! { Router:: {} } } #[component] fn UserFrame(cx: Scope, user_id: usize) -> Element { render! { - pre { - "UserFrame{{\n\tuser_id:{user_id}\n}}" - } - div { - background_color: "rgba(0,0,0,50%)", + pre { "UserFrame{{\n\tuser_id:{user_id}\n}}" } + div { background_color: "rgba(0,0,0,50%)", "children:" Outlet:: {} } @@ -88,6 +134,16 @@ fn Route3(cx: Scope, dynamic: String) -> Element { to: Route::Route2 { user_id: 8888 }, "hello world link" } + button { + disabled: !navigator.can_go_back(), + onclick: move |_| { navigator.go_back(); }, + "go back" + } + button { + disabled: !navigator.can_go_forward(), + onclick: move |_| { navigator.go_forward(); }, + "go forward" + } button { onclick: move |_| { navigator.push("https://www.google.com"); }, "google link" diff --git a/packages/router/src/components/history_buttons.rs b/packages/router/src/components/history_buttons.rs index eb62e9285..ae11ad276 100644 --- a/packages/router/src/components/history_buttons.rs +++ b/packages/router/src/components/history_buttons.rs @@ -142,7 +142,7 @@ pub fn GoForwardButton<'a>(cx: Scope<'a, HistoryButtonProps<'a>>) -> Element { } }; - let disabled = !router.can_go_back(); + let disabled = !router.can_go_forward(); render! { button { diff --git a/packages/router/src/contexts/router.rs b/packages/router/src/contexts/router.rs index b15956991..079640e61 100644 --- a/packages/router/src/contexts/router.rs +++ b/packages/router/src/contexts/router.rs @@ -232,7 +232,7 @@ impl RouterContext { IntoRoutable::FromStr(url) => { let parsed_route: NavigationTarget> = match self.route_from_str(&url) { Ok(route) => NavigationTarget::Internal(route), - Err(err) => NavigationTarget::External(err), + Err(_) => NavigationTarget::External(url), }; parsed_route } diff --git a/packages/router/src/history/liveview.rs b/packages/router/src/history/liveview.rs new file mode 100644 index 000000000..9ffb09c34 --- /dev/null +++ b/packages/router/src/history/liveview.rs @@ -0,0 +1,441 @@ +use super::HistoryProvider; +use crate::routable::Routable; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; +use std::sync::{Mutex, RwLock}; +use std::{collections::BTreeMap, rc::Rc, str::FromStr, sync::Arc}; + +/// A [`HistoryProvider`] that evaluates history through JS. +pub struct LiveviewHistory +where + ::Err: std::fmt::Display, +{ + action_tx: tokio::sync::mpsc::UnboundedSender>, + timeline: Arc>>, + updater_callback: Arc>>, +} + +struct Timeline +where + ::Err: std::fmt::Display, +{ + current_index: usize, + routes: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug)] +struct State { + index: usize, +} + +#[derive(Serialize, Deserialize, Debug)] +struct Session +where + ::Err: std::fmt::Display, +{ + #[serde(with = "routes")] + routes: BTreeMap, + last_visited: usize, +} + +#[derive(Serialize, Deserialize)] +struct SessionStorage { + liveview: Option, +} + +enum Action { + GoBack, + GoForward, + Push(R), + Replace(R), + External(String), +} + +impl Timeline +where + ::Err: std::fmt::Display, +{ + fn new(initial_path: R) -> Self { + Self { + current_index: 0, + routes: BTreeMap::from([(0, initial_path)]), + } + } + + fn init( + &mut self, + route: R, + state: Option, + session: Option>, + depth: usize, + ) -> State { + if let Some(session) = session { + self.routes = session.routes; + if state.is_none() { + // top of stack + let last_visited = session.last_visited; + self.routes.retain(|&lhs, _| lhs <= last_visited); + } + }; + let state = match state { + Some(state) => { + self.current_index = state.index; + state + } + None => { + let index = depth - 1; + self.current_index = index; + State { index } + } + }; + self.routes.insert(state.index, route); + state + } + + fn update(&mut self, route: R, state: Option) -> State { + if let Some(state) = state { + self.current_index = state.index; + self.routes.insert(self.current_index, route); + state + } else { + self.push(route) + } + } + + fn push(&mut self, route: R) -> State { + // top of stack + let index = self.current_index + 1; + self.current_index = index; + self.routes.insert(index, route); + self.routes.retain(|&rhs, _| index >= rhs); + State { + index: self.current_index, + } + } + + fn replace(&mut self, route: R) -> State { + self.routes.insert(self.current_index, route); + State { + index: self.current_index, + } + } + + fn current_route(&self) -> &R { + &self.routes[&self.current_index] + } + + fn session(&self) -> Session { + Session { + routes: self.routes.clone(), + last_visited: self.current_index, + } + } +} + +impl LiveviewHistory +where + ::Err: std::fmt::Display, +{ + /// Create a [`LiveviewHistory`] in the given scope. + /// When using a [`LiveviewHistory`] in combination with use_eval, history must be untampered with. + /// + /// # Panics + /// + /// Panics if not in a Liveview context. + pub fn new(cx: &ScopeState) -> Self { + Self::new_with_initial_path( + cx, + "/".parse().unwrap_or_else(|err| { + panic!("index route does not exist:\n{}\n use LiveviewHistory::new_with_initial_path to set a custom path", err) + }), + ) + } + + /// Create a [`LiveviewHistory`] in the given scope, starting at `initial_path`. + /// When using a [`LiveviewHistory`] in combination with use_eval, history must be untampered with. + /// + /// # Panics + /// + /// Panics if not in a Liveview context. + pub fn new_with_initial_path(cx: &ScopeState, initial_path: R) -> Self { + let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel::>(); + let action_rx = Arc::new(Mutex::new(action_rx)); + let timeline = Arc::new(Mutex::new(Timeline::new(initial_path))); + let updater_callback: Arc>> = + Arc::new(RwLock::new(Arc::new(|| {}))); + + let eval_provider = cx + .consume_context::>() + .expect("evaluator not provided"); + + let create_eval = Rc::new(move |script: &str| { + eval_provider + .new_evaluator(script.to_string()) + .map(UseEval::new) + }) as Rc Result>; + + // Listen to server actions + cx.push_future({ + let timeline = timeline.clone(); + let action_rx = action_rx.clone(); + let create_eval = create_eval.clone(); + async move { + let mut action_rx = action_rx.lock().expect("unpoisoned mutex"); + loop { + let eval = action_rx.recv().await.expect("sender to exist"); + let _ = match eval { + Action::GoBack => create_eval( + r#" + // this triggers a PopState event + history.back(); + "#, + ), + Action::GoForward => create_eval( + r#" + // this triggers a PopState event + history.forward(); + "#, + ), + Action::Push(route) => { + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.push(route.clone()); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + create_eval(&format!( + r#" + // this does not trigger a PopState event + history.pushState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "# + )) + } + Action::Replace(route) => { + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.replace(route.clone()); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + create_eval(&format!( + r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "# + )) + } + Action::External(url) => create_eval(&format!( + r#" + location.href = "{url}"; + "# + )), + }; + } + } + }); + + // Listen to browser actions + cx.push_future({ + let updater = updater_callback.clone(); + let timeline = timeline.clone(); + let create_eval = create_eval.clone(); + async move { + let popstate_eval = { + let init_eval = create_eval( + r#" + return [ + document.location.pathname + "?" + document.location.search + "\#" + document.location.hash, + history.state, + JSON.parse(sessionStorage.getItem("liveview")), + history.length, + ]; + "#, + ).expect("failed to load state").await.expect("serializable state"); + let (route, state, session, depth) = serde_json::from_value::<( + String, + Option, + Option>, + usize, + )>(init_eval).expect("serializable state"); + let Ok(route) = R::from_str(&route.to_string()) else { + return; + }; + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.init(route.clone(), state, session, depth); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + + // Call the updater callback + (updater.read().unwrap())(); + + create_eval(&format!(r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + + window.addEventListener("popstate", (event) => {{ + dioxus.send([ + document.location.pathname + "?" + document.location.search + "\#" + document.location.hash, + event.state, + ]); + }}); + "#)).expect("failed to initialize popstate") + }; + + loop { + let event = match popstate_eval.recv().await { + Ok(event) => event, + Err(_) => continue, + }; + let (route, state) = serde_json::from_value::<(String, Option)>(event).expect("serializable state"); + let Ok(route) = R::from_str(&route.to_string()) else { + return; + }; + let mut timeline = timeline.lock().expect("unpoisoned mutex"); + let state = timeline.update(route.clone(), state); + let state = serde_json::to_string(&state).expect("serializable state"); + let session = serde_json::to_string(&timeline.session()) + .expect("serializable session"); + + let _ = create_eval(&format!( + r#" + // this does not trigger a PopState event + history.replaceState({state}, "", "{route}"); + sessionStorage.setItem("liveview", '{session}'); + "#)); + + // Call the updater callback + (updater.read().unwrap())(); + } + } + }); + + Self { + action_tx, + timeline, + updater_callback, + } + } +} + +impl HistoryProvider for LiveviewHistory +where + ::Err: std::fmt::Display, +{ + fn go_back(&mut self) { + let _ = self.action_tx.send(Action::GoBack); + } + + fn go_forward(&mut self) { + let _ = self.action_tx.send(Action::GoForward); + } + + fn push(&mut self, route: R) { + let _ = self.action_tx.send(Action::Push(route)); + } + + fn replace(&mut self, route: R) { + let _ = self.action_tx.send(Action::Replace(route)); + } + + fn external(&mut self, url: String) -> bool { + let _ = self.action_tx.send(Action::External(url)); + true + } + + fn current_route(&self) -> R { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + timeline.current_route().clone() + } + + fn can_go_back(&self) -> bool { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + // Check if the one before is contiguous (i.e., not an external page) + let visited_indices: Vec = timeline.routes.keys().cloned().collect(); + visited_indices + .iter() + .position(|&rhs| timeline.current_index == rhs) + .map_or(false, |index| { + index > 0 && visited_indices[index - 1] == timeline.current_index - 1 + }) + } + + fn can_go_forward(&self) -> bool { + let timeline = self.timeline.lock().expect("unpoisoned mutex"); + // Check if the one after is contiguous (i.e., not an external page) + let visited_indices: Vec = timeline.routes.keys().cloned().collect(); + visited_indices + .iter() + .rposition(|&rhs| timeline.current_index == rhs) + .map_or(false, |index| { + index < visited_indices.len() - 1 + && visited_indices[index + 1] == timeline.current_index + 1 + }) + } + + fn updater(&mut self, callback: Arc) { + let mut updater_callback = self.updater_callback.write().unwrap(); + *updater_callback = callback; + } +} + +mod routes { + use crate::prelude::Routable; + use core::str::FromStr; + use serde::de::{MapAccess, Visitor}; + use serde::{ser::SerializeMap, Deserializer, Serializer}; + use std::collections::BTreeMap; + + pub fn serialize(routes: &BTreeMap, serializer: S) -> Result + where + S: Serializer, + R: Routable, + { + let mut map = serializer.serialize_map(Some(routes.len()))?; + for (index, route) in routes.iter() { + map.serialize_entry(&index.to_string(), &route.to_string())?; + } + map.end() + } + + pub fn deserialize<'de, D, R>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + R: Routable, + ::Err: std::fmt::Display, + { + struct BTreeMapVisitor { + marker: std::marker::PhantomData, + } + + impl<'de, R> Visitor<'de> for BTreeMapVisitor + where + R: Routable, + ::Err: std::fmt::Display, + { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with indices and routable values") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut routes = BTreeMap::new(); + while let Some((index, route)) = map.next_entry::()? { + let index = index.parse::().map_err(serde::de::Error::custom)?; + let route = R::from_str(&route).map_err(serde::de::Error::custom)?; + routes.insert(index, route); + } + Ok(routes) + } + } + + deserializer.deserialize_map(BTreeMapVisitor { + marker: std::marker::PhantomData, + }) + } +} diff --git a/packages/router/src/history/mod.rs b/packages/router/src/history/mod.rs index e9668b979..cf7183366 100644 --- a/packages/router/src/history/mod.rs +++ b/packages/router/src/history/mod.rs @@ -22,6 +22,11 @@ pub use web::*; #[cfg(feature = "web")] pub(crate) mod web_history; +#[cfg(feature = "liveview")] +mod liveview; +#[cfg(feature = "liveview")] +pub use liveview::*; + // #[cfg(feature = "web")] // mod web_hash; // #[cfg(feature = "web")] From d4e6876ff648159c78ad008ade4e3b1b469bb996 Mon Sep 17 00:00:00 2001 From: Falco Hirschenberger Date: Fri, 27 Oct 2023 15:29:43 +0200 Subject: [PATCH 14/17] Silence desktop build outputs from cargo (#1584) Silence additional disturbing output from cargo in desktop builds --- packages/cli/src/builder.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/builder.rs b/packages/cli/src/builder.rs index 68c494c42..c85cdc484 100644 --- a/packages/cli/src/builder.rs +++ b/packages/cli/src/builder.rs @@ -254,6 +254,7 @@ pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result Date: Sun, 29 Oct 2023 21:39:07 +0330 Subject: [PATCH 15/17] Make window invisible until the first render (#1588) --- packages/desktop/src/lib.rs | 7 +++++++ packages/desktop/src/webview.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/lib.rs b/packages/desktop/src/lib.rs index badbe3276..bfefaf547 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -161,6 +161,7 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) // iOS panics if we create a window before the event loop is started let props = Rc::new(Cell::new(Some(props))); let cfg = Rc::new(Cell::new(Some(cfg))); + let mut is_visible_before_start = true; event_loop.run(move |window_event, event_loop, control_flow| { *control_flow = ControlFlow::Wait; @@ -210,6 +211,8 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) // Create a dom let dom = VirtualDom::new_with_props(root, props); + is_visible_before_start = cfg.window.window.visible; + let handler = create_new_window( cfg, event_loop, @@ -323,6 +326,10 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) EventData::Ipc(msg) if msg.method() == "initialize" => { let view = webviews.get_mut(&event.1).unwrap(); send_edits(view.dom.rebuild(), &view.desktop_context.webview); + view.desktop_context + .webview + .window() + .set_visible(is_visible_before_start); } EventData::Ipc(msg) if msg.method() == "browser_open" => { diff --git a/packages/desktop/src/webview.rs b/packages/desktop/src/webview.rs index 469aa8068..6e6d26b17 100644 --- a/packages/desktop/src/webview.rs +++ b/packages/desktop/src/webview.rs @@ -13,7 +13,7 @@ pub fn build( proxy: EventLoopProxy, ) -> (WebView, WebContext) { let builder = cfg.window.clone(); - let window = builder.build(event_loop).unwrap(); + let window = builder.with_visible(false).build(event_loop).unwrap(); let file_handler = cfg.file_drop_handler.take(); let custom_head = cfg.custom_head.clone(); let index_file = cfg.custom_index.clone(); From 78120ca0e260c9986612b254da7f4afc95edbe97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 08:44:28 -0500 Subject: [PATCH 16/17] Bump actions/setup-node from 3 to 4 (#1592) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/setup-node dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 500d41564..cb5a1f1bb 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -20,7 +20,7 @@ jobs: steps: # Do our best to cache the toolchain and node install steps - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 16 - name: Install Rust From 51f342417a856dc0dfea3701d1488953879f2d73 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Mon, 30 Oct 2023 14:41:41 -0500 Subject: [PATCH 17/17] add examples to the default watch path --- packages/cli/src/assets/dioxus.toml | 2 +- packages/cli/src/config.rs | 2 +- packages/cli/src/server/mod.rs | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/assets/dioxus.toml b/packages/cli/src/assets/dioxus.toml index 6386fb76e..892a6cdf8 100644 --- a/packages/cli/src/assets/dioxus.toml +++ b/packages/cli/src/assets/dioxus.toml @@ -23,7 +23,7 @@ title = "Dioxus | An elegant GUI library for Rust" index_on_404 = true -watch_path = ["src"] +watch_path = ["src", "examples"] # include `assets` in web platform [web.resource] diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs index 0654dc375..3abf47f0c 100644 --- a/packages/cli/src/config.rs +++ b/packages/cli/src/config.rs @@ -105,7 +105,7 @@ impl Default for DioxusConfig { }, proxy: Some(vec![]), watcher: WebWatcherConfig { - watch_path: Some(vec![PathBuf::from("src")]), + watch_path: Some(vec![PathBuf::from("src"), PathBuf::from("examples")]), reload_html: Some(false), index_on_404: Some(true), }, diff --git a/packages/cli/src/server/mod.rs b/packages/cli/src/server/mod.rs index c6073d787..79a5531b6 100644 --- a/packages/cli/src/server/mod.rs +++ b/packages/cli/src/server/mod.rs @@ -32,7 +32,7 @@ async fn setup_file_watcher Result + Send + 'static>( .watcher .watch_path .clone() - .unwrap_or_else(|| vec![PathBuf::from("src")]); + .unwrap_or_else(|| vec![PathBuf::from("src"), PathBuf::from("examples")]); let watcher_config = config.clone(); let mut watcher = notify::recommended_watcher(move |info: notify::Result| { @@ -121,12 +121,12 @@ async fn setup_file_watcher Result + Send + 'static>( .unwrap(); for sub_path in allow_watch_path { - watcher - .watch( - &config.crate_dir.join(sub_path), - notify::RecursiveMode::Recursive, - ) - .unwrap(); + if let Err(err) = watcher.watch( + &config.crate_dir.join(sub_path), + notify::RecursiveMode::Recursive, + ) { + log::error!("Failed to watch path: {}", err); + } } Ok(watcher) }