Skip to content

Commit c238df9

Browse files
committed
feat: improved template2 ergonomics
This now has the exact same syntax as the old macro and doesn't need to have unreactive types explicitly provided to it, because it can infer them through associated types on the new `MakeRx` and `MakeUnrx` traits.
1 parent 2956009 commit c238df9

File tree

8 files changed

+150
-144
lines changed

8 files changed

+150
-144
lines changed

examples/rx_state/src/about.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use perseus::{get_render_ctx, Html, Template};
33
use sycamore::prelude::{view, Signal};
44
use sycamore::view::View;
55

6-
#[perseus::template2(component = "AboutPage")]
6+
#[perseus::template2(AboutPage)]
77
pub fn about_page() -> View<G> {
88
// Get the page state store manually
99
// The index page is just an empty string

examples/rx_state/src/index.rs

+2-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use perseus::{Html, RenderFnResultWithCause, Template};
22
use sycamore::prelude::*;
33

4-
use crate::global_state::{AppState, AppStateRx};
4+
use crate::global_state::AppStateRx;
55

66
// We define a normal `struct` and then use `make_rx` (which derives `Serialize`, `Deserialize`, and `Clone` automatically)
77
// This will generate a new `struct` called `IndexPropsRx` (as we asked it to), in which every field is made reactive with a `Signal`
@@ -13,11 +13,7 @@ pub struct IndexProps {
1313
// This special macro (normally we'd use `template(IndexProps)`) converts the state we generate elsewhere to a reactive version
1414
// We need to tell it the name of the unreactive properties we created to start with (unfortunately the compiler isn't smart enough to figure that out yet)
1515
// This will also add our reactive properties to the global state store, and, if they're already there, it'll use the existing one
16-
#[perseus::template2(
17-
component = "IndexPage",
18-
unrx_props = "IndexProps",
19-
global_state = "AppState"
20-
)]
16+
#[perseus::template2(IndexPage)]
2117
pub fn index_page(IndexPropsRx { username }: IndexPropsRx, global_state: AppStateRx) -> View<G> {
2218
let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released
2319
view! {

packages/perseus-macro/src/lib.rs

+7-22
Original file line numberDiff line numberDiff line change
@@ -72,38 +72,23 @@ pub fn template(args: TokenStream, input: TokenStream) -> TokenStream {
7272
template::template_impl(parsed, arg).into()
7373
}
7474

75-
/// Exactly the same as `#[template]`, but this expects your state to be reactive (use `#[make_rx]` to make it thus). This will automatically deserialize state and make it reactive,
76-
/// allowing you to use an MVC pattern easily in Perseus. As the second argument, you'll need to provide the name of your unreactive state `struct` (this is unergonomic,
77-
/// but the compiler isn't smart enough to infer it yet).
78-
///
79-
/// Additionally, this macro will add the reactive state to the global state store, and will fetch it from there, allowing template state to persists between page changes. Additionally,
80-
/// that state can be accessed by other templates if necessary.
81-
// TODO Rename this to `template2` and rewrite docs on it with examples
8275
/// The new version of `#[template]` designed for reactive state. This can interface automatically with global state, and will automatically provide Sycamore `#[component]` annotations. To
83-
/// use this, you'll need to provide your component's name (e.g. `IndexPage`) as `#[template2(component_name = )`.
76+
/// use this, you'll need to provide your component's name (e.g. `IndexPage`) as `#[template2(IndexPage)]` (just like with the old macro). You can also provide a custom type parameter
77+
/// name to use for your component (defaults to `G`) as the second argument.
8478
///
85-
/// The first argument your template function can take is state generated for it (e.g. by the *build state* strategy). If you use this, you'll need to provide the key `unrx_props` as well to this
86-
/// macro. The argument your template function takes should be the reactive version of your state `struct` (generated with `#[make_rx]` usually), and then you can tell us the name of unreactive
87-
/// version with `unrx_props = `.
79+
/// The first argument your template function can take is state generated for it (e.g. by the *build state* strategy), but the reactive version (created with `#[make_rx]` usually). From this,
80+
/// Perseus can infer the other required types and automatically make your state reactive for you.
8881
///
89-
/// The second argument your template function can take is a global state generated with the `GlobalStateCreator`. If you provide this, with its type being the reactive version, you'll need to
90-
/// provide the key `global_state = ` being the unreactive version.
82+
/// The second argument your template function can take is a global state generated with the `GlobalStateCreator`. You should also provide the reactive type here, and Perseus will do all the
83+
/// rest in the background.
9184
///
9285
/// **Warning:** this macro is currently exempt from semantic versioning, and breaking changes may be introduced here at any time! If you want stability, use the `#[template]` macro (but you won't
9386
/// get access to Perseus' reactive state platform).
9487
#[proc_macro_attribute]
9588
pub fn template2(args: TokenStream, input: TokenStream) -> TokenStream {
9689
let parsed = syn::parse_macro_input!(input as template2::TemplateFn);
9790
let attr_args = syn::parse_macro_input!(args as syn::AttributeArgs);
98-
// Parse macro arguments with `darling`
99-
let args = match template2::TemplateArgs::from_list(&attr_args) {
100-
Ok(v) => v,
101-
Err(e) => {
102-
return TokenStream::from(e.write_errors());
103-
}
104-
};
105-
106-
template2::template_impl(parsed, args).into()
91+
template2::template_impl(parsed, attr_args).into()
10792
}
10893

10994
/// Labels a function as a Perseus head function, which is very similar to a template, but

packages/perseus-macro/src/rx_state.rs

+12-8
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@ pub fn make_rx_impl(mut orig_struct: ItemStruct, name: Ident) -> TokenStream {
88
// 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
99
let mut new_struct = orig_struct.clone();
1010
let ItemStruct {
11-
vis,
12-
ident,
13-
generics,
14-
..
11+
ident, generics, ..
1512
} = orig_struct.clone();
1613

1714
new_struct.ident = name.clone();
@@ -158,12 +155,19 @@ pub fn make_rx_impl(mut orig_struct: ItemStruct, name: Ident) -> TokenStream {
158155
// 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
159156
#[derive(::serde::Serialize, ::serde::Deserialize, ::std::clone::Clone)]
160157
#orig_struct
158+
impl#generics ::perseus::state::MakeRx for #ident#generics {
159+
type Rx = #name#generics;
160+
fn make_rx(self) -> #name#generics {
161+
#make_rx_fields
162+
}
163+
}
161164
#[derive(::std::clone::Clone)]
162165
#new_struct
163-
impl#generics #ident#generics {
164-
/// Converts an instance of `#ident` into an instance of `#name`, making it reactive. This consumes `self`.
165-
#vis fn make_rx(self) -> #name {
166-
#make_rx_fields
166+
impl#generics ::perseus::state::MakeUnrx for #name#generics {
167+
type Unrx = #ident#generics;
168+
fn make_unrx(self) -> #ident#generics {
169+
todo!()
170+
// #make_rx_fields
167171
}
168172
}
169173
}

packages/perseus-macro/src/template2.rs

+44-45
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
use darling::FromMeta;
21
use proc_macro2::{Span, TokenStream};
32
use quote::quote;
43
use syn::parse::{Parse, ParseStream};
54
use syn::{
6-
Attribute, Block, FnArg, Generics, Ident, Item, ItemFn, PatType, Result, ReturnType, Type,
7-
Visibility,
5+
Attribute, AttributeArgs, Block, FnArg, Generics, Ident, Item, ItemFn, NestedMeta, PatType,
6+
Result, ReturnType, Type, Visibility,
87
};
98

109
/// A function that can be wrapped in the Perseus test sub-harness.
@@ -99,22 +98,7 @@ impl Parse for TemplateFn {
9998
}
10099
}
101100

102-
#[derive(FromMeta)]
103-
pub struct TemplateArgs {
104-
/// The name of the component.
105-
component: Ident,
106-
/// The name of the type parameter to use (default to `G`).
107-
#[darling(default)]
108-
type_param: Option<Ident>,
109-
/// The identifier of the global state type, if this template needs it.
110-
#[darling(default)]
111-
global_state: Option<Ident>,
112-
/// The name of the unreactive properties, if there are any.
113-
#[darling(default)]
114-
unrx_props: Option<Ident>,
115-
}
116-
117-
pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
101+
pub fn template_impl(input: TemplateFn, attr_args: AttributeArgs) -> TokenStream {
118102
let TemplateFn {
119103
block,
120104
// We know that these are all typed (none are `self`)
@@ -126,34 +110,38 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
126110
return_type,
127111
} = input;
128112

129-
let component_name = &args.component;
130-
let type_param = match &args.type_param {
131-
Some(type_param) => type_param.clone(),
132-
None => Ident::new("G", Span::call_site()),
133-
};
134-
// This is only optional if the second argument wasn't provided
135-
let global_state = if fn_args.len() == 2 {
136-
match &args.global_state {
137-
Some(global_state) => global_state.clone(),
138-
None => return syn::Error::new_spanned(&fn_args[0], "template functions with two arguments must declare their global state type (`global_state = `)").to_compile_error()
113+
// We want either one or two arguments
114+
if attr_args.is_empty() || attr_args.len() > 2 {
115+
return quote!(compile_error!(
116+
"this macro takes either one or two arguments"
117+
));
118+
}
119+
// This must always be provided
120+
let component_name = match &attr_args[0] {
121+
NestedMeta::Meta(meta) if meta.path().get_ident().is_some() => {
122+
meta.path().get_ident().unwrap()
139123
}
140-
} else {
141-
match &args.global_state {
142-
Some(global_state) => global_state.clone(),
143-
None => Ident::new("Dummy", Span::call_site()),
124+
nested_meta => {
125+
return syn::Error::new_spanned(
126+
nested_meta,
127+
"first argument must be a component identifier",
128+
)
129+
.to_compile_error()
144130
}
145131
};
146-
// This is only optional if the first argument wasn't provided
147-
let unrx_props = if !fn_args.is_empty() {
148-
match &args.unrx_props {
149-
Some(unrx_props) => unrx_props.clone(),
150-
None => return syn::Error::new_spanned(&fn_args[0], "template functions with one argument or more must declare their unreactive properties type (`unrx_props = `)").to_compile_error()
132+
// But this is optional (we'll use `G` as the default if it's not provided)
133+
let type_param = match &attr_args.get(1) {
134+
Some(NestedMeta::Meta(meta)) if meta.path().get_ident().is_some() => {
135+
meta.path().get_ident().unwrap().clone()
151136
}
152-
} else {
153-
match &args.unrx_props {
154-
Some(unrx_props) => unrx_props.clone(),
155-
None => Ident::new("Dummy", Span::call_site()),
137+
Some(nested_meta) => {
138+
return syn::Error::new_spanned(
139+
nested_meta,
140+
"optional second argument must be a type parameter identifier if it's provided",
141+
)
142+
.to_compile_error()
156143
}
144+
None => Ident::new("G", Span::call_site()),
157145
};
158146

159147
// We create a wrapper function that can be easily provided to `.template()` that does deserialization automatically if needed
@@ -162,6 +150,10 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
162150
// There's an argument for page properties that needs to have state extracted, so the wrapper will deserialize it
163151
// We'll also make it reactive and add it to the page state store
164152
let state_arg = &fn_args[0];
153+
let rx_props_ty = match state_arg {
154+
FnArg::Typed(PatType { ty, .. }) => ty,
155+
FnArg::Receiver(_) => unreachable!(),
156+
};
165157
// There's also a second argument for the global state, which we'll deserialize and make global if it's not already (aka. if any other pages have loaded before this one)
166158
// Sycamore won't let us have more than one argument to a component though, so we sneakily extract it and literally construct it as a variable (this should be fine?)
167159
let global_state_arg = &fn_args[1];
@@ -171,6 +163,7 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
171163
};
172164
quote! {
173165
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
166+
use ::perseus::state::MakeRx;
174167
// Deserialize the global state, make it reactive, and register it with the `RenderCtx`
175168
// If it's already there, we'll leave it
176169
// This means that we can pass an `Option<String>` around safely and then deal with it at the template site
@@ -183,7 +176,7 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
183176
let mut global_state = global_state_refcell.borrow_mut();
184177
// This will be defined if we're the first page
185178
let global_state_props = &props.global_state.unwrap();
186-
let new_global_state = ::serde_json::from_str::<#global_state>(global_state_props).unwrap().make_rx();
179+
let new_global_state = ::serde_json::from_str::<<#global_state_rx as ::perseus::state::MakeUnrx>::Unrx>(global_state_props).unwrap().make_rx();
187180
*global_state = ::std::boxed::Box::new(new_global_state);
188181
// The component function can now access this in `RenderCtx`
189182
}
@@ -212,7 +205,7 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
212205
::std::option::Option::None => {
213206
// If there are props, they will always be provided, the compiler just doesn't know that
214207
// If the user is using this macro, they sure should be using `#[make_rx(...)]` or similar!
215-
let rx_props = ::serde_json::from_str::<#unrx_props>(&props.state.unwrap()).unwrap().make_rx();
208+
let rx_props: #rx_props_ty = ::serde_json::from_str::<<#rx_props_ty as ::perseus::state::MakeUnrx>::Unrx>(&props.state.unwrap()).unwrap().make_rx();
216209
// They aren't in there, so insert them
217210
pss.add(&props.path, rx_props.clone());
218211
rx_props
@@ -227,8 +220,13 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
227220
// There's an argument for page properties that needs to have state extracted, so the wrapper will deserialize it
228221
// We'll also make it reactive and add it to the page state store
229222
let arg = &fn_args[0];
223+
let rx_props_ty = match arg {
224+
FnArg::Typed(PatType { ty, .. }) => ty,
225+
FnArg::Receiver(_) => unreachable!(),
226+
};
230227
quote! {
231228
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
229+
use ::perseus::state::MakeRx;
232230
// The user's function, with Sycamore component annotations and the like preserved
233231
// We know this won't be async because Sycamore doesn't allow that
234232
#(#attrs)*
@@ -247,7 +245,7 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
247245
::std::option::Option::None => {
248246
// If there are props, they will always be provided, the compiler just doesn't know that
249247
// If the user is using this macro, they sure should be using `#[make_rx(...)]` or similar!
250-
let rx_props = ::serde_json::from_str::<#unrx_props>(&props.state.unwrap()).unwrap().make_rx();
248+
let rx_props: #rx_props_ty = ::serde_json::from_str::<<#rx_props_ty as ::perseus::state::MakeUnrx>::Unrx>(&props.state.unwrap()).unwrap().make_rx();
251249
// They aren't in there, so insert them
252250
pss.add(&props.path, rx_props.clone());
253251
rx_props
@@ -262,6 +260,7 @@ pub fn template_impl(input: TemplateFn, args: TemplateArgs) -> TokenStream {
262260
// There are no arguments
263261
quote! {
264262
#vis fn #name<G: ::sycamore::prelude::Html>(props: ::perseus::templates::PageProps) -> ::sycamore::prelude::View<G> {
263+
use ::perseus::state::MakeRx;
265264
// The user's function, with Sycamore component annotations and the like preserved
266265
// We know this won't be async because Sycamore doesn't allow that
267266
#(#attrs)*

packages/perseus/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ mod page_data;
5858
mod page_state_store;
5959
mod path_prefix;
6060
mod router;
61+
mod rx_state;
6162
mod server;
6263
mod shell;
6364
mod template;
@@ -101,6 +102,7 @@ pub mod templates {
101102
pub mod state {
102103
pub use crate::global_state::GlobalStateCreator;
103104
pub use crate::page_state_store::PageStateStore;
105+
pub use crate::rx_state::{MakeRx, MakeUnrx};
104106
}
105107
/// A series of exports that should be unnecessary for nearly all uses of Perseus. These are used principally in developing alternative
106108
/// engines.

0 commit comments

Comments
 (0)