Skip to content

Commit

Permalink
feat: added macro to enable fine-grained reactive state
Browse files Browse the repository at this point in the history
This is a temporary workaround until Sycamore's observables, but it's
very effective.
  • Loading branch information
arctic-hen7 committed Jan 18, 2022
1 parent 407d515 commit e12d15c
Show file tree
Hide file tree
Showing 5 changed files with 338 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/perseus-macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ syn = "1"
proc-macro2 = "1"
darling = "0.13"
serde_json = "1"
sycamore-reactive = "^0.7.1"

[dev-dependencies]
trybuild = { version = "1.0", features = ["diff"] }
73 changes: 73 additions & 0 deletions packages/perseus-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@

mod autoserde;
mod head;
mod rx_state;
mod template;
mod test;

use darling::FromMeta;
use proc_macro::TokenStream;
use syn::ItemStruct;

/// Automatically serializes/deserializes properties for a template. Perseus handles your templates' properties as `String`s under the
/// hood for both simplicity and to avoid bundle size increases from excessive monomorphization. This macro aims to prevent the need for
Expand Down Expand Up @@ -94,3 +96,74 @@ pub fn test(args: TokenStream, input: TokenStream) -> TokenStream {

test::test_impl(parsed, args).into()
}

/// Processes the given `struct` to create a reactive version by wrapping each field in a `Signal`. This will generate a new `struct` with the given name and implement a `.make_rx()`
/// method on the original that allows turning an instance of the unreactive `struct` into an instance of the reactive one.
///
/// This macro automatically derives `serde::Serialize` and `serde::Deserialize` on the original `struct`, so do NOT add these yourself, or errors will occur. Note that you can still
/// use Serde helper macros (e.g. `#[serde(rename = "testField")]`) as usual.
///
/// If one of your fields is itself a `struct`, by default it will just be wrapped in a `Signal`, but you can also enable nested fine-grained reactivity by adding the
/// `#[rx::nested("field_name", FieldTypeRx)]` helper attribute to the `struct` (not the field, that isn't supported by Rust yet), where `field_name` is the name of the field you want
/// to use ensted reactivity on, and `FieldTypeRx` is the wrapper type that will be expected. This should be created by using this macro on the original `struct` type.
///
/// Note that this will be deprecated or significantly altered by Sycamore's new observables system (when it's released). For that reason, this doesn't support more advanced
/// features like leaving some fields unreactive, this is an all-or-nothing solution for now.
///
/// # Examples
///
/// ```rust
/// #[make_rx(TestRx)]
/// #[derive(Clone)] // Notice that we don't need to derive `Serialize` and `Deserialize`, the macro does it for us
/// #[rx::nested("nested", NestedRx)]
/// struct Test {
/// #[serde(rename = "foo_test")]
/// foo: String,
/// bar: u16,
/// // This will get simple reactivity
/// baz: Baz,
/// // This will get fine-grained reactivity
/// // We use the unreactive type in the declaration, and tell the macro what the reactive type is in the annotation above
/// nested: Nested
/// }
/// // On unreactive types, we'll need to derive `Serialize` and `Deserialize` as usual
/// #[derive(Serialize, Deserialize, Clone)]
/// struct Baz {
/// test: String
/// }
/// #[perseus_macro::make_rx(NestedRx)]
/// #[derive(Clone)]
/// struct Nested {
/// test: String
/// }
///
/// let new = Test {
/// foo: "foo".to_string(),
/// bar: 5,
/// baz: Baz {
/// // We won't be able to `.set()` this
/// test: "test".to_string()
/// },
/// nested: Nested {
/// // We will be able to `.set()` this
/// test: "nested".to_string()
/// }
/// }.make_rx();
/// // Simple reactivity
/// new.bar.set(6);
/// // Simple reactivity on a `struct`
/// new.baz.set(Baz {
/// test: "updated".to_string()
/// });
/// // Nested reactivity on a `struct`
/// new.nested.test.set("updated".to_string());
/// // Our own derivations still remain
/// let new_2 = new.clone();
/// ```
#[proc_macro_attribute]
pub fn make_rx(args: TokenStream, input: TokenStream) -> TokenStream {
let parsed = syn::parse_macro_input!(input as ItemStruct);
let name = syn::parse_macro_input!(args as syn::Ident);

rx_state::make_rx_impl(parsed, name).into()
}
169 changes: 169 additions & 0 deletions packages/perseus-macro/src/rx_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
use std::collections::HashMap;

use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::{Ident, ItemStruct, Lit, Meta, NestedMeta, Result};

pub fn make_rx_impl(mut orig_struct: ItemStruct, name: Ident) -> TokenStream {
// So that we don't have to worry about unit structs or unnamed fields, we'll just copy the struct and change the parts we want to
let mut new_struct = orig_struct.clone();
let ItemStruct {
vis,
ident,
generics,
..
} = orig_struct.clone();

new_struct.ident = name.clone();
// Reset the attributes entirely (we don't want any Serde derivations in there)
// Look through the attributes for any that warn about nested fields
// These can't exist on the fields themselves because they'd be parsed before this macro, and tehy're technically invalid syntax (grr.)
// When we come across these fields, we'll run `.make_rx()` on them instead of naively wrapping them in a `Signal`
let nested_fields = new_struct
.attrs
.iter()
// We only care about our own attributes
.filter(|attr| {
attr.path.segments.len() == 2
&& attr.path.segments.first().unwrap().ident == "rx"
&& attr.path.segments.last().unwrap().ident == "nested"
})
// Remove any attributes that can't be parsed as a `MetaList`, returning the internal list of what can (the 'arguments' to the attribute)
// We need them to be two elements long (a field name and a wrapper type)
.filter_map(|attr| match attr.parse_meta() {
Ok(Meta::List(list)) if list.nested.len() == 2 => Some(list.nested),
_ => None,
})
// Now parse the tokens within these to an `(Ident, Ident)`, the first being the name of the field and the second being the wrapper type to use
.map(|meta_list| {
// Extract field name and wrapper type (we know this only has two elements)
let field_name = match meta_list.first().unwrap() {
NestedMeta::Lit(Lit::Str(s)) => Ident::new(s.value().as_str(), Span::call_site()),
NestedMeta::Lit(val) => {
return Err(syn::Error::new_spanned(
val,
"first argument must be string literal field name",
))
}
NestedMeta::Meta(meta) => {
return Err(syn::Error::new_spanned(
meta,
"first argument must be string literal field name",
))
}
};
let wrapper_ty = match meta_list.last().unwrap() {
// TODO Is this `.unwrap()` actually safe to use?
NestedMeta::Meta(meta) => &meta.path().segments.first().unwrap().ident,
NestedMeta::Lit(val) => {
return Err(syn::Error::new_spanned(
val,
"second argument must be reactive wrapper type",
))
}
};

Ok::<(Ident, Ident), syn::Error>((field_name, wrapper_ty.clone()))
})
.collect::<Vec<Result<(Ident, Ident)>>>();
// Handle any errors produced by that final transformation and create a map
let mut nested_fields_map = HashMap::new();
for res in nested_fields {
match res {
Ok((k, v)) => nested_fields_map.insert(k, v),
Err(err) => return err.to_compile_error(),
};
}
// Now remove our attributes from both the original and the new `struct`s
let mut filtered_attrs_orig = Vec::new();
let mut filtered_attrs_new = Vec::new();
for attr in orig_struct.attrs.iter() {
if !(attr.path.segments.len() == 2
&& attr.path.segments.first().unwrap().ident == "rx"
&& attr.path.segments.last().unwrap().ident == "nested")
{
filtered_attrs_orig.push(attr.clone());
filtered_attrs_new.push(attr.clone());
}
}
orig_struct.attrs = filtered_attrs_orig;
new_struct.attrs = filtered_attrs_new;

match new_struct.fields {
syn::Fields::Named(ref mut fields) => {
for field in fields.named.iter_mut() {
let orig_ty = &field.ty;
// Check if this field was registered as one to use nested reactivity
let wrapper_ty = nested_fields_map.get(field.ident.as_ref().unwrap());
field.ty = if let Some(wrapper_ty) = wrapper_ty {
syn::Type::Verbatim(quote!(#wrapper_ty))
} else {
syn::Type::Verbatim(quote!(::sycamore::prelude::Signal<#orig_ty>))
};
// Remove any `serde` attributes (Serde can't be used with the reactive version)
let mut new_attrs = Vec::new();
for attr in field.attrs.iter() {
if !(attr.path.segments.len() == 1
&& attr.path.segments.first().unwrap().ident == "serde")
{
new_attrs.push(attr.clone());
}
}
field.attrs = new_attrs;
}
}
syn::Fields::Unnamed(_) => return syn::Error::new_spanned(
new_struct,
"tuple structs can't be made reactive with this macro (try using named fields instead)",
)
.to_compile_error(),
syn::Fields::Unit => {
return syn::Error::new_spanned(
new_struct,
"it's pointless to make a unit struct reactive since it has no fields",
)
.to_compile_error()
}
};

// Create a list of fields for the `.make_rx()` method
let make_rx_fields = match new_struct.fields {
syn::Fields::Named(ref mut fields) => {
let mut field_assignments = quote!();
for field in fields.named.iter_mut() {
// We know it has an identifier because it's a named field
let field_name = field.ident.as_ref().unwrap();
// Check if this field was registered as one to use nested reactivity
if nested_fields_map.contains_key(field.ident.as_ref().unwrap()) {
field_assignments.extend(quote! {
#field_name: self.#field_name.make_rx(),
})
} else {
field_assignments.extend(quote! {
#field_name: ::sycamore::prelude::Signal::new(self.#field_name),
});
}
}
quote! {
#name {
#field_assignments
}
}
}
// We filtered out the other types before
_ => unreachable!(),
};

quote! {
// We add a Serde derivation because it will always be necessary for Perseus on the original `struct`, and it's really difficult and brittle to filter it out
#[derive(::serde::Serialize, ::serde::Deserialize)]
#orig_struct
#new_struct
impl#generics #ident#generics {
/// Converts an instance of `#ident` into an instance of `#name`, making it reactive. This consumes `self`.
#vis fn make_rx(self) -> #name {
#make_rx_fields
}
}
}
}
93 changes: 93 additions & 0 deletions packages/perseus/src/global_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// use std::any::{Any, TypeId};
// use std::collections::HashMap;
//
// /// A container for global state in Perseus. This is designed as a context store, in which one of each type can be stored. Therefore, it acts very similarly to Sycamore's context system,
// /// though it's specifically designed for each template to store one reactive properties object. In theory, you could interact with this entirely independently of Perseus' state interface,
// /// though this isn't recommended.
// ///
// /// For now, `struct`s stored in global state should have tehir reactivity managed by the inserter (usually the Perseus interface). However, this will change radically when Sycamore's
// /// proposals for fine-grained reactivity are stabilized.
// #[derive(Default)]
// pub struct GlobalState {
// /// A map of type IDs to anything, allowing one storage of each type (each type is intended to a properties `struct` for a template). Entries must be `Clone`able becasue we assume them
// /// to be `Signal`s or `struct`s composed of `Signal`s.
// map: HashMap<TypeId, Box<dyn Any>>,
// }
// impl GlobalState {
// pub fn get<T: Any>(&self) -> Option<T> {
// let type_id = TypeId::of::<T>();
// todo!()
// // match self.map.get(&type_id) {

// // }
// }
// }

// These are tests for the `#[make_rx]` proc macro (here temporarily)
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};

#[test]
fn named_fields() {
#[perseus_macro::make_rx(TestRx)]
struct Test {
foo: String,
bar: u16,
}

let new = Test {
foo: "foo".to_string(),
bar: 5,
}
.make_rx();
new.bar.set(6);
}

#[test]
fn nested() {
#[perseus_macro::make_rx(TestRx)]
// The Serde derivations will be stripped from the reactive version, but others will remain
#[derive(Clone)]
#[rx::nested("nested", NestedRx)]
struct Test {
#[serde(rename = "foo_test")]
foo: String,
bar: u16,
// This will get simple reactivity
// This annotation is unnecessary though
baz: Baz,
// This will get fine-grained reactivity
nested: Nested,
}
#[derive(Serialize, Deserialize, Clone)]
struct Baz {
test: String,
}
#[perseus_macro::make_rx(NestedRx)]
#[derive(Clone)]
struct Nested {
test: String,
}

let new = Test {
foo: "foo".to_string(),
bar: 5,
baz: Baz {
// We won't be able to `.set()` this
test: "test".to_string(),
},
nested: Nested {
// We will be able to `.set()` this
test: "nested".to_string(),
},
}
.make_rx();
new.bar.set(6);
new.baz.set(Baz {
test: "updated".to_string(),
});
new.nested.test.set("updated".to_string());
let new_2 = new.clone();
}
}
3 changes: 2 additions & 1 deletion packages/perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ mod decode_time_str;
mod default_headers;
mod error_pages;
mod export;
mod global_state;
mod html_shell;
mod locale_detector;
mod locales;
Expand All @@ -70,7 +71,7 @@ pub use http::Request as HttpRequest;
pub use wasm_bindgen_futures::spawn_local;
/// All HTTP requests use empty bodies for simplicity of passing them around. They'll never need payloads (value in path requested).
pub type Request = HttpRequest<()>;
pub use perseus_macro::{autoserde, head, template, test};
pub use perseus_macro::{autoserde, head, make_rx, template, test};
pub use sycamore::{generic_node::Html, DomNode, HydrateNode, SsrNode};
pub use sycamore_router::{navigate, Route};

Expand Down

0 comments on commit e12d15c

Please sign in to comment.