Skip to content

Commit e12d15c

Browse files
committed
feat: added macro to enable fine-grained reactive state
This is a temporary workaround until Sycamore's observables, but it's very effective.
1 parent 407d515 commit e12d15c

File tree

5 files changed

+338
-1
lines changed

5 files changed

+338
-1
lines changed

packages/perseus-macro/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ syn = "1"
2525
proc-macro2 = "1"
2626
darling = "0.13"
2727
serde_json = "1"
28+
sycamore-reactive = "^0.7.1"
2829

2930
[dev-dependencies]
3031
trybuild = { version = "1.0", features = ["diff"] }

packages/perseus-macro/src/lib.rs

+73
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828

2929
mod autoserde;
3030
mod head;
31+
mod rx_state;
3132
mod template;
3233
mod test;
3334

3435
use darling::FromMeta;
3536
use proc_macro::TokenStream;
37+
use syn::ItemStruct;
3638

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

9597
test::test_impl(parsed, args).into()
9698
}
99+
100+
/// 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()`
101+
/// method on the original that allows turning an instance of the unreactive `struct` into an instance of the reactive one.
102+
///
103+
/// 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
104+
/// use Serde helper macros (e.g. `#[serde(rename = "testField")]`) as usual.
105+
///
106+
/// 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
107+
/// `#[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
108+
/// 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.
109+
///
110+
/// 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
111+
/// features like leaving some fields unreactive, this is an all-or-nothing solution for now.
112+
///
113+
/// # Examples
114+
///
115+
/// ```rust
116+
/// #[make_rx(TestRx)]
117+
/// #[derive(Clone)] // Notice that we don't need to derive `Serialize` and `Deserialize`, the macro does it for us
118+
/// #[rx::nested("nested", NestedRx)]
119+
/// struct Test {
120+
/// #[serde(rename = "foo_test")]
121+
/// foo: String,
122+
/// bar: u16,
123+
/// // This will get simple reactivity
124+
/// baz: Baz,
125+
/// // This will get fine-grained reactivity
126+
/// // We use the unreactive type in the declaration, and tell the macro what the reactive type is in the annotation above
127+
/// nested: Nested
128+
/// }
129+
/// // On unreactive types, we'll need to derive `Serialize` and `Deserialize` as usual
130+
/// #[derive(Serialize, Deserialize, Clone)]
131+
/// struct Baz {
132+
/// test: String
133+
/// }
134+
/// #[perseus_macro::make_rx(NestedRx)]
135+
/// #[derive(Clone)]
136+
/// struct Nested {
137+
/// test: String
138+
/// }
139+
///
140+
/// let new = Test {
141+
/// foo: "foo".to_string(),
142+
/// bar: 5,
143+
/// baz: Baz {
144+
/// // We won't be able to `.set()` this
145+
/// test: "test".to_string()
146+
/// },
147+
/// nested: Nested {
148+
/// // We will be able to `.set()` this
149+
/// test: "nested".to_string()
150+
/// }
151+
/// }.make_rx();
152+
/// // Simple reactivity
153+
/// new.bar.set(6);
154+
/// // Simple reactivity on a `struct`
155+
/// new.baz.set(Baz {
156+
/// test: "updated".to_string()
157+
/// });
158+
/// // Nested reactivity on a `struct`
159+
/// new.nested.test.set("updated".to_string());
160+
/// // Our own derivations still remain
161+
/// let new_2 = new.clone();
162+
/// ```
163+
#[proc_macro_attribute]
164+
pub fn make_rx(args: TokenStream, input: TokenStream) -> TokenStream {
165+
let parsed = syn::parse_macro_input!(input as ItemStruct);
166+
let name = syn::parse_macro_input!(args as syn::Ident);
167+
168+
rx_state::make_rx_impl(parsed, name).into()
169+
}
+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
use std::collections::HashMap;
2+
3+
use proc_macro2::{Span, TokenStream};
4+
use quote::quote;
5+
use syn::{Ident, ItemStruct, Lit, Meta, NestedMeta, Result};
6+
7+
pub fn make_rx_impl(mut orig_struct: ItemStruct, name: Ident) -> TokenStream {
8+
// 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
9+
let mut new_struct = orig_struct.clone();
10+
let ItemStruct {
11+
vis,
12+
ident,
13+
generics,
14+
..
15+
} = orig_struct.clone();
16+
17+
new_struct.ident = name.clone();
18+
// Reset the attributes entirely (we don't want any Serde derivations in there)
19+
// Look through the attributes for any that warn about nested fields
20+
// These can't exist on the fields themselves because they'd be parsed before this macro, and tehy're technically invalid syntax (grr.)
21+
// When we come across these fields, we'll run `.make_rx()` on them instead of naively wrapping them in a `Signal`
22+
let nested_fields = new_struct
23+
.attrs
24+
.iter()
25+
// We only care about our own attributes
26+
.filter(|attr| {
27+
attr.path.segments.len() == 2
28+
&& attr.path.segments.first().unwrap().ident == "rx"
29+
&& attr.path.segments.last().unwrap().ident == "nested"
30+
})
31+
// Remove any attributes that can't be parsed as a `MetaList`, returning the internal list of what can (the 'arguments' to the attribute)
32+
// We need them to be two elements long (a field name and a wrapper type)
33+
.filter_map(|attr| match attr.parse_meta() {
34+
Ok(Meta::List(list)) if list.nested.len() == 2 => Some(list.nested),
35+
_ => None,
36+
})
37+
// 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
38+
.map(|meta_list| {
39+
// Extract field name and wrapper type (we know this only has two elements)
40+
let field_name = match meta_list.first().unwrap() {
41+
NestedMeta::Lit(Lit::Str(s)) => Ident::new(s.value().as_str(), Span::call_site()),
42+
NestedMeta::Lit(val) => {
43+
return Err(syn::Error::new_spanned(
44+
val,
45+
"first argument must be string literal field name",
46+
))
47+
}
48+
NestedMeta::Meta(meta) => {
49+
return Err(syn::Error::new_spanned(
50+
meta,
51+
"first argument must be string literal field name",
52+
))
53+
}
54+
};
55+
let wrapper_ty = match meta_list.last().unwrap() {
56+
// TODO Is this `.unwrap()` actually safe to use?
57+
NestedMeta::Meta(meta) => &meta.path().segments.first().unwrap().ident,
58+
NestedMeta::Lit(val) => {
59+
return Err(syn::Error::new_spanned(
60+
val,
61+
"second argument must be reactive wrapper type",
62+
))
63+
}
64+
};
65+
66+
Ok::<(Ident, Ident), syn::Error>((field_name, wrapper_ty.clone()))
67+
})
68+
.collect::<Vec<Result<(Ident, Ident)>>>();
69+
// Handle any errors produced by that final transformation and create a map
70+
let mut nested_fields_map = HashMap::new();
71+
for res in nested_fields {
72+
match res {
73+
Ok((k, v)) => nested_fields_map.insert(k, v),
74+
Err(err) => return err.to_compile_error(),
75+
};
76+
}
77+
// Now remove our attributes from both the original and the new `struct`s
78+
let mut filtered_attrs_orig = Vec::new();
79+
let mut filtered_attrs_new = Vec::new();
80+
for attr in orig_struct.attrs.iter() {
81+
if !(attr.path.segments.len() == 2
82+
&& attr.path.segments.first().unwrap().ident == "rx"
83+
&& attr.path.segments.last().unwrap().ident == "nested")
84+
{
85+
filtered_attrs_orig.push(attr.clone());
86+
filtered_attrs_new.push(attr.clone());
87+
}
88+
}
89+
orig_struct.attrs = filtered_attrs_orig;
90+
new_struct.attrs = filtered_attrs_new;
91+
92+
match new_struct.fields {
93+
syn::Fields::Named(ref mut fields) => {
94+
for field in fields.named.iter_mut() {
95+
let orig_ty = &field.ty;
96+
// Check if this field was registered as one to use nested reactivity
97+
let wrapper_ty = nested_fields_map.get(field.ident.as_ref().unwrap());
98+
field.ty = if let Some(wrapper_ty) = wrapper_ty {
99+
syn::Type::Verbatim(quote!(#wrapper_ty))
100+
} else {
101+
syn::Type::Verbatim(quote!(::sycamore::prelude::Signal<#orig_ty>))
102+
};
103+
// Remove any `serde` attributes (Serde can't be used with the reactive version)
104+
let mut new_attrs = Vec::new();
105+
for attr in field.attrs.iter() {
106+
if !(attr.path.segments.len() == 1
107+
&& attr.path.segments.first().unwrap().ident == "serde")
108+
{
109+
new_attrs.push(attr.clone());
110+
}
111+
}
112+
field.attrs = new_attrs;
113+
}
114+
}
115+
syn::Fields::Unnamed(_) => return syn::Error::new_spanned(
116+
new_struct,
117+
"tuple structs can't be made reactive with this macro (try using named fields instead)",
118+
)
119+
.to_compile_error(),
120+
syn::Fields::Unit => {
121+
return syn::Error::new_spanned(
122+
new_struct,
123+
"it's pointless to make a unit struct reactive since it has no fields",
124+
)
125+
.to_compile_error()
126+
}
127+
};
128+
129+
// Create a list of fields for the `.make_rx()` method
130+
let make_rx_fields = match new_struct.fields {
131+
syn::Fields::Named(ref mut fields) => {
132+
let mut field_assignments = quote!();
133+
for field in fields.named.iter_mut() {
134+
// We know it has an identifier because it's a named field
135+
let field_name = field.ident.as_ref().unwrap();
136+
// Check if this field was registered as one to use nested reactivity
137+
if nested_fields_map.contains_key(field.ident.as_ref().unwrap()) {
138+
field_assignments.extend(quote! {
139+
#field_name: self.#field_name.make_rx(),
140+
})
141+
} else {
142+
field_assignments.extend(quote! {
143+
#field_name: ::sycamore::prelude::Signal::new(self.#field_name),
144+
});
145+
}
146+
}
147+
quote! {
148+
#name {
149+
#field_assignments
150+
}
151+
}
152+
}
153+
// We filtered out the other types before
154+
_ => unreachable!(),
155+
};
156+
157+
quote! {
158+
// 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
159+
#[derive(::serde::Serialize, ::serde::Deserialize)]
160+
#orig_struct
161+
#new_struct
162+
impl#generics #ident#generics {
163+
/// Converts an instance of `#ident` into an instance of `#name`, making it reactive. This consumes `self`.
164+
#vis fn make_rx(self) -> #name {
165+
#make_rx_fields
166+
}
167+
}
168+
}
169+
}

packages/perseus/src/global_state.rs

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// use std::any::{Any, TypeId};
2+
// use std::collections::HashMap;
3+
//
4+
// /// 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,
5+
// /// 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,
6+
// /// though this isn't recommended.
7+
// ///
8+
// /// 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
9+
// /// proposals for fine-grained reactivity are stabilized.
10+
// #[derive(Default)]
11+
// pub struct GlobalState {
12+
// /// 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
13+
// /// to be `Signal`s or `struct`s composed of `Signal`s.
14+
// map: HashMap<TypeId, Box<dyn Any>>,
15+
// }
16+
// impl GlobalState {
17+
// pub fn get<T: Any>(&self) -> Option<T> {
18+
// let type_id = TypeId::of::<T>();
19+
// todo!()
20+
// // match self.map.get(&type_id) {
21+
22+
// // }
23+
// }
24+
// }
25+
26+
// These are tests for the `#[make_rx]` proc macro (here temporarily)
27+
#[cfg(test)]
28+
mod tests {
29+
use serde::{Deserialize, Serialize};
30+
31+
#[test]
32+
fn named_fields() {
33+
#[perseus_macro::make_rx(TestRx)]
34+
struct Test {
35+
foo: String,
36+
bar: u16,
37+
}
38+
39+
let new = Test {
40+
foo: "foo".to_string(),
41+
bar: 5,
42+
}
43+
.make_rx();
44+
new.bar.set(6);
45+
}
46+
47+
#[test]
48+
fn nested() {
49+
#[perseus_macro::make_rx(TestRx)]
50+
// The Serde derivations will be stripped from the reactive version, but others will remain
51+
#[derive(Clone)]
52+
#[rx::nested("nested", NestedRx)]
53+
struct Test {
54+
#[serde(rename = "foo_test")]
55+
foo: String,
56+
bar: u16,
57+
// This will get simple reactivity
58+
// This annotation is unnecessary though
59+
baz: Baz,
60+
// This will get fine-grained reactivity
61+
nested: Nested,
62+
}
63+
#[derive(Serialize, Deserialize, Clone)]
64+
struct Baz {
65+
test: String,
66+
}
67+
#[perseus_macro::make_rx(NestedRx)]
68+
#[derive(Clone)]
69+
struct Nested {
70+
test: String,
71+
}
72+
73+
let new = Test {
74+
foo: "foo".to_string(),
75+
bar: 5,
76+
baz: Baz {
77+
// We won't be able to `.set()` this
78+
test: "test".to_string(),
79+
},
80+
nested: Nested {
81+
// We will be able to `.set()` this
82+
test: "nested".to_string(),
83+
},
84+
}
85+
.make_rx();
86+
new.bar.set(6);
87+
new.baz.set(Baz {
88+
test: "updated".to_string(),
89+
});
90+
new.nested.test.set("updated".to_string());
91+
let new_2 = new.clone();
92+
}
93+
}

packages/perseus/src/lib.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mod decode_time_str;
4848
mod default_headers;
4949
mod error_pages;
5050
mod export;
51+
mod global_state;
5152
mod html_shell;
5253
mod locale_detector;
5354
mod locales;
@@ -70,7 +71,7 @@ pub use http::Request as HttpRequest;
7071
pub use wasm_bindgen_futures::spawn_local;
7172
/// All HTTP requests use empty bodies for simplicity of passing them around. They'll never need payloads (value in path requested).
7273
pub type Request = HttpRequest<()>;
73-
pub use perseus_macro::{autoserde, head, template, test};
74+
pub use perseus_macro::{autoserde, head, make_rx, template, test};
7475
pub use sycamore::{generic_node::Html, DomNode, HydrateNode, SsrNode};
7576
pub use sycamore_router::{navigate, Route};
7677

0 commit comments

Comments
 (0)