diff --git a/.project-words.txt b/.project-words.txt index cf194b074f..07128f0c8f 100644 --- a/.project-words.txt +++ b/.project-words.txt @@ -100,6 +100,7 @@ TTFB unbuilt unergonomic unfetched +uninteractive unlocalized unminified unreactive diff --git a/docs/next/en-US/SUMMARY.md b/docs/next/en-US/SUMMARY.md index 45bbbee24b..d1b049b26b 100644 --- a/docs/next/en-US/SUMMARY.md +++ b/docs/next/en-US/SUMMARY.md @@ -12,6 +12,7 @@ - [Feature Discovery Terminal](/docs/features) - [Improving Compilation Times](/docs/reference/compilation-times) +- [State Platform](/docs/reference/state-platform) - [State Generation](/docs/reference/state-generation) - [Live Reloading and HSR](/docs/reference/live-reloading-and-hsr) - [Internationalization](/docs/reference/i18n) diff --git a/docs/next/en-US/reference/state-platform.md b/docs/next/en-US/reference/state-platform.md new file mode 100644 index 0000000000..c95a2b2ecc --- /dev/null +++ b/docs/next/en-US/reference/state-platform.md @@ -0,0 +1,107 @@ +# The State Platform + +One of the features Perseus proclaims most is its advanced *state platform*, which isn't the simplest concept to explain, but it forms the 'secret sauce' that makes Perseus so powerful. As discussed on the [core principles page](:core-principles), Perseus uses a template/page model, such that a page is the product of state going into a template, and you can generate that state in all sorts of ways. Now, let's dive into the specifics on this. + +One of the most powerful features of Perseus' state platform is that it spans both the engine-side and the browser-side: you can generate state in all sorts of ways on the engine-side (see the [state generation page](:reference/state-generation)), and then, when that state gets to your pages, it's 'reactive'. But what does this actually mean? Well, let's take an example state that a page in a music app might use: + +```rust +#[derive(Serialize, Deserialize, ReactiveState)] +struct Song { + title: String, + #[rx(nested)] + artist: Artist, + year: u32, + #[rx(nested)] + album: Album, +} +#[derive(Serialize, Deserialize, ReactiveState)] +struct Album { + title: String, + #[rx(nested)] + artist: Artist, + year: u32, + ty: AlbumType, + cover_art_url: String, +} +#[derive(Serialize, Deserialize)] +enum AlbumType { + Single, + EP, + Album, +} +#[derive(Serialize, Deserialize, ReactiveState)] +struct Artist { + name: String, + bio: String, + profile_pic_url: String, +} +``` + +Now, this is pretty complex for an example, and rightly so, we're going to dive into exactly how a real app might use Perseus' reactive state platform! Note that this will be a bit of a contrived example, since you probably wouldn't need *reactive* state in a music app, but that means we can use this example for both reactive and unreactive state! + +## State Generation + +The first step in all this is actually getting some instances of this state, since we can't do anything with the state unless we know what it is! Above, we've defined a schema for it, but we need some actual values in there now. If we imagine there's a database of everything we need, we could use one of Perseus' many [state generation strategies](:reference/state-generation) to get that state at build-time, or even incrementally as users visit certain URLs, getting state as necessary. Since there's a [whole separate page](:reference/state-generation) on this, we'll leave it there for this, just imagine we've somehow gotten instances of our state into Perseus. Note that this stage will also involve defining all the paths under the URL `/song` that we want to create. + +## Passing State to a Template + +Now that we have our many states (one for each song), we need to pass it to our `song` template, and use it to generate a number of pages. **This entire stage is automatic, and occurs behind the scenes.** In essence, Perseus will take in all the paths you've told it about, and it will get the state for those in parallel (e.g. you read a database to tell it about all the songs, and then it fetches each one and gets its state), building all the pages you need. Now, obviously, this involves sending the state you've generated to a page (we'll focus on just one page from now on, for simplicity), so how does this happen? + +Well, when you generated state, you generated an instance of `Song`, but, if we want our state to be *reactive*, then we'll have to do better. Reactive state is state that you can call `.get()` and `.set()` on. The most obvious usage of it is inside a form: let's say you're building a user interface that involves the user inputting some values, well, you could use Sycamore's `bind:value` on each of the `input` elements to store the state of each input reactively. But, rather than creating all the variables to do this inside your template, you can accept these as state, like so: + +```rust +#[template] +fn my_template<'a, G: Html>(cx: Scope<'a>, state: PageStateRx<'a>) -> View { + view! { cx, + form { + input(bind:value = state.name, placeholder = "Name") + input(bind:value = state.email, placeholder = "Email") + // ... + } + } +} +``` + +See what we mean? It's much more convenient if every single one of the fields of `state` is *reactive*, meaning it's wrapped in a Sycamore `Signal`. (If you haven't read the Sycamore docs yet, now would be a good time!) Otherwise, you'd have to create all the `Signal`s you need at the start of your template function. + +But this isn't just for convenience, it also serves a practical function: Perseus automatically caches all reactive state internally, meaning the changes the user makes to those inputs will be reflected inside Perseus' cache. And, when they come back to that page later, *the state will be restored from the cache*, meaning the inputs are just as they left them. This means users can navigate fearlessly around any Perseus app using reactive state, without fear of losing their place. + +*(It actually gets even better than this, but keep reading!)* + +Now, what matters behind the scenes is that we can turn the unreactive state you gave to Perseus into reactive state. Since we're making all the fields of the `Song` `struct` reactive, in the above example, this will involve a macro: `#[derive(ReactiveState)]` (we also derive `Serialize` and `Deserialize` from Serde, since Perseus needs to send this state over a network connection from server to browser). Now, this derive macro is more complex than most: it takes in the `struct` you give it, and derives the `MakeRx` trait on it, which means it can be converted into some reactive type. Then, it actually *creates* a whole new `struct` called `SongPerseusRxIntermediate` (which you should never have to touch) that has all its fields wrapped in `RcSignal`s. The reason we don't just go straight to a `Signal` is because, as we mentioned earlier, Perseus caches all reactive state at the application-level, which means it has to outlive all your templates, so, for the lifetimes to work out, we use `RcSignal`s. + +Now, if you've worked with lifetimes long enough in Sycamore (no problem if you haven't), you'll know that this will lead to some really poor ergonomics: using `RcSignal`s, we would have to `.clone()` almost everything we want to use inside `view!`. But, this is where that macro comes to the rescue again! It creates *another* `struct` called `SongPerseusRxRef` (which you shouldn't have to touch by that name, we'll get to naming), which has all the fields of the original `Song` wrapped in `&'cx RcSignal`, where `cx` is the lifetime of the page the state is being used in. Basically, you can imagine it like this: we take unreactive state, make it reactive at the application-level, and then register it as a reference on each page it's used in when we need to, to get the best ergonomics possible. + +But, if it encounters an `#[rx(nested)]` helper macro on any of your fields, it will assume the type of that field also has `ReactiveState` derived, and it will automatically use the reactive version of it. In our example above, this means we wouldn't be getting the artist of a song's name by going `song.artist.get().name`, we could use the far better `song.artist.name.get()`! This improves ergonomics substantially in complex apps (while also allowing *very* fine-grained state control). + +[TODO implementation on `Vec` etc.] + +Importantly, especially if you ever need to implement all this without the macro (e.g. if your page's state is an `enum` rather than a `struct`), the intermediate reactive type (the one with pure `RcSignal`s) implements three traits: `MakeUnrx` (which allows it to be turned back into a `Song`), `MakeRxRef` (which allows it to be turned into the final type using references), and `Freeze` (we'll get to this). The original `Song` just implements `MakeRx`, and the final reference `struct` implements `RxRef`, a simple linking trait that has no methods, but that just defines the `RxNonRef` associated type to be the intermediate type. By linking the three types together like this, Perseus can take in whichever is most ergonomically convenient and work with it! For instance, there are plenty of internal methods that have access to the intermediate type, but that need to go back to the original, and they easily can with this mechanism. + +So, in the `#[template]` macro, Perseus takes in your generated, unreactive state, and checks if a reactive version has already been cached (e.g. the user has already been to this page). If there is, it'll use that, and, otherwise, it'll make the unreactive thing it was given reactive, cache that for the first time for future use, and then give a reference version to your code! Since this code is basically the same for every template, we do it with a macro to minimize the overhead. + +*Note: there are plans currently to remove the `#[template]` macro entirely, eventually, though this will involve significant alterations to the Perseus core.* + +Of course, you probably don't want to reference your reactive type using something like `<::Rx as ::perseus::state::MakeRxRef>::RxRef<'__derived_rx>;`, so you can use the `#[rx(alias = "SongRx")]` helper macro to define an alias for the final reactive reference `struct`, which takes the same lifetime as the Sycamore `Scope` of the page it's being used in. + +## Unreactive State + +The other thing we could do is have out song state be unreactive, since, after all, it's pretty unlikely that the user is going to be renaming a song inside our music app (remember that the `.set()` method simply changes the state locally, it doesn't change anything on the engine-side or in a database, unless you code that yourself). + +To do this, we would remove all those `#[rx(nested)]` helper macros, and simply change `ReactiveState` to `UnreactiveState` in that `#[derive(...)]` call at the top. (We also wouldn't need to derive `ReactiveState` or `UnreactiveState` on anything other than `Song`). Importantly, you'll also need to change `#[template]` to `#[template(unreactive)]` in the function you're using to render `Song`s. + +Now, you're probably wondering why on earth we have to specially derive `UnreactiveState`, when we're just going to get the exact same thing as we generated! Well, your type still has implement the special `Freeze` trait, and the Perseus state platform is built for storing explicitly reactive state. So, what that `UnreactiveState` derive macro actually does is basically exactly the same as the `ReactiveState` macro, except, rather than wrapping your fields in `Signal`s, it uses a special `UnreactiveState` wrapper, which basically makes your state *look* reactive to Perseus, but, when you use `#[template(unreactive)]`, it can know to get rid of those wrappers and give you the original type you generated. + +## Freezing + +Earlier, we mentioned a `Freeze` trait that the intermediate reactive type implements, which is the core of Perseus' unique *state freezing* system. Up to now, we have the ability with Perseus to let users go between pages and have the state of each page stored perfectly, as long as they're still on the site. Of course, once they leave the site, or reload the page, that will all be lost, but what if we could preserve it somehow? + +Well, conveniently, all the state in a Perseus app has to be both `Serialize` and `Deserialize`, since it needs to be turned into a `String` to be sent from the server to the user's browser. But, what if we took all the intermediate reactive types in an app, converted them back into their unreactive versions, and serialized those to `String`s? What if we added some internal Perseus state, any global state, and the current route? Put that all together as a JSON object, and you would have a `String` representation of the *exact* state of an app, from a user's perspective. + +*That* is what the `Freeze` trait enables. As explained above, to achieve this, you would need to take each intermediate reactive type, turn it into its unreactive version, and serialize it to a `String`. To allow flexibility in this, Perseus requires such intermediate types to implement `Freeze`, which just has the `.freeze()` method, which simply produces a `String` representation of that type. + +If you want to see how you can freeze and entire Perseus app, check out [this example](https://github.com/framesurge/perseus/tree/main/examples/core/freezing_and_thawing). Alternately, take a look at [this one](https://github.com/framesurge/perseus/tree/main/examples/core/idb_freezing) to see how you can easily store the resulting frozen app to IndexedDB (a native in-browser storage system)! + +Of course, if we can freeze, we need to be able to *thaw* too, right? Well, Perseus makes this pretty much trivial, since you can register a frozen app `String` that will be progressively unwrapped as necessary. In essence, rather than restoring the whole state of the app at once, Perseus simply restores the internal state and takes the user to whatever route they were on at the last freeze, and then restores each page's state on-demand: we leave it frozen until the user goes to that exact page. The same thing applies to the global state: it will only be thawed when it's first used. That not only mitigates the need for you to specify all your state types to a single thaw command, but it also means that any page states that are no longer valid can be silently ignored (e.g. if the app's code has changed since the freeze). This means users get the best replication of the previous state possible, even if the thaw is years later. + +As a side note, this is exactly how Perseus' HSR (hot state reloading) system works! In JavaScript frameworks, you can split up all your JS files into many small *chunks*, and then, when you as a developer change some code and want to see the result, the framework just reloads the necessary chunks, meaning most things should stay the same. Since we can't chunk Wasm files (yet), Perseus just freezes your app's state to IndexedDB, and restores it after a page reload. From your perspective, the page has reloaded to exactly the same place. When you're fifteen pages into a login form and trying to realign a button, making sure you don't have to go back to beginning of that flow whenever you change some code becomes pretty useful! That said, you can easily disable HSR by turning off the default feature flag `hsr`. diff --git a/examples/core/basic/src/templates/index.rs b/examples/core/basic/src/templates/index.rs index 160d251e51..036415bea2 100644 --- a/examples/core/basic/src/templates/index.rs +++ b/examples/core/basic/src/templates/index.rs @@ -1,7 +1,9 @@ -use perseus::{Html, RenderFnResultWithCause, SsrNode, Template}; -use sycamore::prelude::{view, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(IndexPageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "IndexPageStateRx")] pub struct IndexPageState { pub greeting: String, } diff --git a/examples/core/freezing_and_thawing/src/global_state.rs b/examples/core/freezing_and_thawing/src/global_state.rs index fbafee3e7d..2f3e86d260 100644 --- a/examples/core/freezing_and_thawing/src/global_state.rs +++ b/examples/core/freezing_and_thawing/src/global_state.rs @@ -1,10 +1,12 @@ -use perseus::{state::GlobalStateCreator, RenderFnResult}; +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; pub fn get_global_state_creator() -> GlobalStateCreator { GlobalStateCreator::new().build_state_fn(get_build_state) } -#[perseus::make_rx(AppStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "AppStateRx")] pub struct AppState { pub test: String, } diff --git a/examples/core/freezing_and_thawing/src/templates/index.rs b/examples/core/freezing_and_thawing/src/templates/index.rs index f3d362ce79..2f8b01deb9 100644 --- a/examples/core/freezing_and_thawing/src/templates/index.rs +++ b/examples/core/freezing_and_thawing/src/templates/index.rs @@ -1,10 +1,10 @@ -use perseus::prelude::*; -use perseus::state::Freeze; -use sycamore::prelude::*; - use crate::global_state::AppStateRx; +use perseus::{prelude::*, state::Freeze}; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(IndexPropsRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "IndexPropsRx")] pub struct IndexProps { username: String, } diff --git a/examples/core/global_state/src/global_state.rs b/examples/core/global_state/src/global_state.rs index fbafee3e7d..2f3e86d260 100644 --- a/examples/core/global_state/src/global_state.rs +++ b/examples/core/global_state/src/global_state.rs @@ -1,10 +1,12 @@ -use perseus::{state::GlobalStateCreator, RenderFnResult}; +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; pub fn get_global_state_creator() -> GlobalStateCreator { GlobalStateCreator::new().build_state_fn(get_build_state) } -#[perseus::make_rx(AppStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "AppStateRx")] pub struct AppState { pub test: String, } diff --git a/examples/core/global_state/src/templates/index.rs b/examples/core/global_state/src/templates/index.rs index fa124e53f7..2b1fc17385 100644 --- a/examples/core/global_state/src/templates/index.rs +++ b/examples/core/global_state/src/templates/index.rs @@ -1,8 +1,7 @@ +use crate::global_state::AppStateRx; use perseus::prelude::*; use sycamore::prelude::*; -use crate::global_state::AppStateRx; - // Note that this template takes no state of its own in this example, but it // certainly could #[perseus::template] diff --git a/examples/core/i18n/src/templates/post.rs b/examples/core/i18n/src/templates/post.rs index ab594feb2a..6c6da246a9 100644 --- a/examples/core/i18n/src/templates/post.rs +++ b/examples/core/i18n/src/templates/post.rs @@ -1,7 +1,9 @@ -use perseus::{link, RenderFnResult, RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PostPageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PostPageStateRx")] pub struct PostPageState { title: String, content: String, diff --git a/examples/core/idb_freezing/src/global_state.rs b/examples/core/idb_freezing/src/global_state.rs index fbafee3e7d..2f3e86d260 100644 --- a/examples/core/idb_freezing/src/global_state.rs +++ b/examples/core/idb_freezing/src/global_state.rs @@ -1,10 +1,12 @@ -use perseus::{state::GlobalStateCreator, RenderFnResult}; +use perseus::{prelude::*, state::GlobalStateCreator}; +use serde::{Deserialize, Serialize}; pub fn get_global_state_creator() -> GlobalStateCreator { GlobalStateCreator::new().build_state_fn(get_build_state) } -#[perseus::make_rx(AppStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "AppStateRx")] pub struct AppState { pub test: String, } diff --git a/examples/core/idb_freezing/src/templates/index.rs b/examples/core/idb_freezing/src/templates/index.rs index 30ba843684..5bc9c4500d 100644 --- a/examples/core/idb_freezing/src/templates/index.rs +++ b/examples/core/idb_freezing/src/templates/index.rs @@ -1,9 +1,10 @@ +use crate::global_state::AppStateRx; use perseus::prelude::*; +use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -use crate::global_state::AppStateRx; - -#[perseus::make_rx(IndexPropsRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "IndexPropsRx")] pub struct IndexProps { username: String, } diff --git a/examples/core/rx_state/src/templates/index.rs b/examples/core/rx_state/src/templates/index.rs index c8b2b018e3..630831c496 100644 --- a/examples/core/rx_state/src/templates/index.rs +++ b/examples/core/rx_state/src/templates/index.rs @@ -1,7 +1,9 @@ -use perseus::{Html, RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Scope, SsrNode, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(IndexPageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "IndexPageStateRx")] pub struct IndexPageState { pub username: String, } diff --git a/examples/core/set_headers/src/templates/index.rs b/examples/core/set_headers/src/templates/index.rs index 4a7085b7fc..1df77f3e04 100644 --- a/examples/core/set_headers/src/templates/index.rs +++ b/examples/core/set_headers/src/templates/index.rs @@ -1,7 +1,9 @@ -use perseus::{Html, RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Scope, SsrNode, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] struct PageState { greeting: String, } diff --git a/examples/core/state_generation/src/templates/amalgamation.rs b/examples/core/state_generation/src/templates/amalgamation.rs index 41bc31f843..80528d6ad3 100644 --- a/examples/core/state_generation/src/templates/amalgamation.rs +++ b/examples/core/state_generation/src/templates/amalgamation.rs @@ -1,9 +1,9 @@ -#[cfg(not(target_arch = "wasm32"))] -use perseus::Request; -use perseus::{RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { pub message: String, } diff --git a/examples/core/state_generation/src/templates/build_paths.rs b/examples/core/state_generation/src/templates/build_paths.rs index ef7574f0f4..a2e284b0af 100644 --- a/examples/core/state_generation/src/templates/build_paths.rs +++ b/examples/core/state_generation/src/templates/build_paths.rs @@ -1,7 +1,9 @@ -use perseus::{RenderFnResult, RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { title: String, content: String, @@ -57,6 +59,6 @@ pub async fn get_build_paths() -> RenderFnResult> { "".to_string(), "test".to_string(), "blah/test/blah".to_string(), - "a test".to_string(), // Perseus caan even handle paths with special characters! + "a test".to_string(), // Perseus can even handle paths with special characters! ]) } diff --git a/examples/core/state_generation/src/templates/build_state.rs b/examples/core/state_generation/src/templates/build_state.rs index 998825fe4d..e896faa27c 100644 --- a/examples/core/state_generation/src/templates/build_state.rs +++ b/examples/core/state_generation/src/templates/build_state.rs @@ -1,7 +1,9 @@ -use perseus::{RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { pub greeting: String, } diff --git a/examples/core/state_generation/src/templates/incremental_generation.rs b/examples/core/state_generation/src/templates/incremental_generation.rs index e84309b9ff..766fc2452d 100644 --- a/examples/core/state_generation/src/templates/incremental_generation.rs +++ b/examples/core/state_generation/src/templates/incremental_generation.rs @@ -1,10 +1,12 @@ // This is exactly the same as the build paths example except for a few lines // and some names -use perseus::{blame_err, RenderFnResult, RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { title: String, content: String, diff --git a/examples/core/state_generation/src/templates/request_state.rs b/examples/core/state_generation/src/templates/request_state.rs index 2aeddf18aa..14da941a22 100644 --- a/examples/core/state_generation/src/templates/request_state.rs +++ b/examples/core/state_generation/src/templates/request_state.rs @@ -1,9 +1,9 @@ -#[cfg(not(target_arch = "wasm32"))] -use perseus::Request; -use perseus::{RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { ip: String, } diff --git a/examples/core/state_generation/src/templates/revalidation.rs b/examples/core/state_generation/src/templates/revalidation.rs index 4e5c93eb44..c16d9f26d7 100644 --- a/examples/core/state_generation/src/templates/revalidation.rs +++ b/examples/core/state_generation/src/templates/revalidation.rs @@ -1,7 +1,9 @@ -use perseus::{RenderFnResultWithCause, Template}; -use sycamore::prelude::{view, Html, Scope, View}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { pub time: String, } diff --git a/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs b/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs index 3a51e427f9..62daf993ec 100644 --- a/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs +++ b/examples/core/state_generation/src/templates/revalidation_and_incremental_generation.rs @@ -1,11 +1,13 @@ // This page exists mostly for testing revalidation together with incremental // generation (because the two work in complex ways together) -use perseus::{RenderFnResult, RenderFnResultWithCause, Template}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; use std::time::Duration; -use sycamore::prelude::{view, Html, Scope, View}; +use sycamore::prelude::*; -#[perseus::make_rx(PageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PageStateRx")] pub struct PageState { pub time: String, } diff --git a/examples/demos/auth/src/global_state.rs b/examples/demos/auth/src/global_state.rs index 5ff523eec7..b1055c07bc 100644 --- a/examples/demos/auth/src/global_state.rs +++ b/examples/demos/auth/src/global_state.rs @@ -1,4 +1,4 @@ -use perseus::{state::GlobalStateCreator, RenderFnResult}; +use perseus::{prelude::*, state::GlobalStateCreator}; use serde::{Deserialize, Serialize}; pub fn get_global_state_creator() -> GlobalStateCreator { @@ -16,10 +16,11 @@ pub async fn get_build_state() -> RenderFnResult { }) } -#[perseus::make_rx(AppStateRx)] -#[rx::nested("auth", AuthDataRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "AppStateRx")] pub struct AppState { /// Authentication data accessible to all pages. + #[rx(nested)] pub auth: AuthData, } @@ -37,7 +38,8 @@ pub enum LoginState { /// Authentication data for the app. // In a real app, you might store privileges here, or user preferences, etc. (all the things you'd // need to have available constantly and everywhere) -#[perseus::make_rx(AuthDataRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "AuthDataRx")] pub struct AuthData { /// The actual login status. pub state: LoginState, diff --git a/examples/demos/auth/src/templates/index.rs b/examples/demos/auth/src/templates/index.rs index 9a6019aeab..af4fe681de 100644 --- a/examples/demos/auth/src/templates/index.rs +++ b/examples/demos/auth/src/templates/index.rs @@ -1,7 +1,8 @@ -use crate::global_state::*; use perseus::prelude::*; use sycamore::prelude::*; +use crate::global_state::*; + #[perseus::template] fn index_view<'a, G: Html>(cx: Scope<'a>) -> View { let AppStateRx { auth } = RenderCtx::from_ctx(cx).get_global_state::(cx); diff --git a/examples/demos/fetching/src/templates/index.rs b/examples/demos/fetching/src/templates/index.rs index 0390e3887c..ebe4932c91 100644 --- a/examples/demos/fetching/src/templates/index.rs +++ b/examples/demos/fetching/src/templates/index.rs @@ -1,7 +1,9 @@ -use perseus::{Html, RenderFnResultWithCause, Template}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; use sycamore::prelude::*; -#[perseus::make_rx(IndexPageStateRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "IndexPageStateRx")] pub struct IndexPageState { server_ip: String, browser_ip: Option, diff --git a/examples/website/app_in_a_file/src/main.rs b/examples/website/app_in_a_file/src/main.rs index 4b74f75a5b..a96adca10e 100644 --- a/examples/website/app_in_a_file/src/main.rs +++ b/examples/website/app_in_a_file/src/main.rs @@ -1,4 +1,5 @@ -use perseus::{Html, PerseusApp, RenderFnResultWithCause, Template}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; use sycamore::prelude::*; // Initialize our app with the `perseus_warp` package's default server (fully @@ -31,7 +32,8 @@ fn index_page<'a, G: Html>(cx: Scope<'a>, props: IndexPropsRx<'a>) -> View { } } -#[perseus::make_rx(IndexPropsRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "IndexPropsRx")] struct IndexProps { name: String, } diff --git a/examples/website/state_generation/src/main.rs b/examples/website/state_generation/src/main.rs index 7716588b1d..35a266c097 100644 --- a/examples/website/state_generation/src/main.rs +++ b/examples/website/state_generation/src/main.rs @@ -1,4 +1,5 @@ -use perseus::{blame_err, Html, PerseusApp, RenderFnResult, RenderFnResultWithCause, Template}; +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; use std::time::Duration; use sycamore::prelude::*; @@ -31,7 +32,8 @@ fn post_page<'a, G: Html>(cx: Scope<'a>, props: PostRx<'a>) -> View { } // EXCERPT_END -#[perseus::make_rx(PostRx)] +#[derive(Serialize, Deserialize, ReactiveState)] +#[rx(alias = "PostRx")] struct Post { title: String, author: String, diff --git a/packages/perseus-macro/src/lib.rs b/packages/perseus-macro/src/lib.rs index a41c2f872a..5a7e016a79 100644 --- a/packages/perseus-macro/src/lib.rs +++ b/packages/perseus-macro/src/lib.rs @@ -18,11 +18,13 @@ mod state_fns; mod template; mod test; -use darling::FromMeta; +use darling::{FromDeriveInput, FromMeta}; use proc_macro::TokenStream; use quote::quote; use state_fns::StateFnType; -use syn::{DeriveInput, ItemStruct, Path}; +use syn::{DeriveInput, Path}; + +use crate::rx_state::ReactiveStateDeriveInput; /// Annotates functions used for generating state at build time to support /// automatic serialization/deserialization of app state and client/server @@ -145,7 +147,7 @@ pub fn template_rx(_args: TokenStream, _input: TokenStream) -> TokenStream { /// /// *Note: in previous versions of Perseus, there was a `template_rx` macro, /// which has become this. The old unreactive `template` macro has become -/// `#[template(unreactive)]`. For thos used to using Sycamore `#[component]` +/// `#[template(unreactive)]`. For those used to using Sycamore `#[component]` /// annotation on their pages, this is no longer required. Note also that global /// state is now accessed through the `.get_global_state()` method on Perseus' /// `RenderCtx`.* @@ -267,85 +269,20 @@ pub fn engine_main(_args: TokenStream, input: TokenStream) -> TokenStream { /// 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. `Clone` will also be derived on both the -/// original and the new `struct`, so do NOT try to derive it yourself. -/// /// 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 instead -/// 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,ignore -/// use serde::{Serialize, Deserialize}; -/// use perseus::make_rx; -/// // We need this trait in scope to manually invoke `.make_rx()` -/// use perseus::state::MakeRx; -/// -/// #[make_rx(TestRx)] -/// // Notice that we don't need to derive `Serialize`,`Deserialize`, or `Clone`, 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)] -/// 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); +/// reactivity by adding the `#[rx(nested)]` helper macro to the field. +/// Fields that have nested reactivity should also use this derive macro. +#[proc_macro_derive(ReactiveState, attributes(rx))] +pub fn reactive_state(input: TokenStream) -> TokenStream { + let input = match ReactiveStateDeriveInput::from_derive_input(&syn::parse_macro_input!( + input as DeriveInput + )) { + Ok(input) => input, + Err(err) => return err.write_errors().into(), + }; - rx_state::make_rx_impl(parsed, name).into() + rx_state::make_rx_impl(input).into() } /// Marks the annotated code as only to be run as part of the engine (the diff --git a/packages/perseus-macro/src/rx_state.rs b/packages/perseus-macro/src/rx_state.rs index a1e5239ea8..ab7e4e3d76 100644 --- a/packages/perseus-macro/src/rx_state.rs +++ b/packages/perseus-macro/src/rx_state.rs @@ -1,320 +1,501 @@ -use std::collections::HashMap; - +use darling::{ast::Data, FromDeriveInput, FromField, ToTokens}; use proc_macro2::{Span, TokenStream}; use quote::quote; -use syn::{GenericParam, Ident, ItemStruct, Lifetime, LifetimeDef, Lit, Meta, NestedMeta, Result}; +use syn::{Attribute, Ident, Type, Visibility}; -pub fn make_rx_impl(mut orig_struct: ItemStruct, name_raw: Ident) -> TokenStream { - // Note: we create three `struct`s with this macro: the original, the new one - // (with references), and the new one (intermediary without references, stored - // in context) 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 - // We won't create the final `struct` yet to avoid more operations than - // necessary - // Note that we leave this as whatever visibility the original state was to - // avoid compiler errors (since it will be exposed as a trait-linked type - // through the ref struct) - let mut mid_struct = orig_struct.clone(); // This will use `RcSignal`s, and will be stored in context - let ItemStruct { - ident: orig_name, - generics, - .. - } = orig_struct.clone(); +/// This is used to parse what the user gives us with `darling`. +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(rx))] +pub struct ReactiveStateDeriveInput { + /// If specified, a type alias will be created for the final reactive + /// `struct` for ease of reference. + #[darling(default)] + alias: Option, - let ref_name = name_raw.clone(); - let mid_name = Ident::new( - &(name_raw.to_string() + "PerseusRxIntermediary"), - Span::call_site(), - ); - mid_struct.ident = mid_name.clone(); - // 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 they're technically invalid syntax (grr.) When we come - // across these fields, we'll run `.make_rx()` on them instead of naively - // wrapping them in an `RcSignal` - let nested_fields = mid_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", - )) - } - }; + ident: Ident, + vis: Visibility, + // The first of these is only relevant if we're parsing `enum`s, which we aren't + pub data: Data, + attrs: Vec, +} - Ok::<(Ident, Ident), syn::Error>((field_name, wrapper_ty.clone())) - }) - .collect::>>(); - // 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 all the `struct`s - let mut filtered_attrs = 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.push(attr.clone()); - } - } - orig_struct.attrs = filtered_attrs.clone(); - mid_struct.attrs = filtered_attrs; +/// This is used to parse each individual field in what the user gives us. +#[derive(Debug, FromField, Clone)] +#[darling(attributes(rx))] +pub struct ReactiveStateField { + /// Whether or not we should expect the annotated field to be able to made + /// reactive itself, enabling nested reactivity. + #[darling(default)] + nested: bool, - // Now define the final `struct` that uses references - let mut ref_struct = mid_struct.clone(); - ref_struct.ident = ref_name.clone(); - // Add the `'rx` lifetime to the generics - // We also need a separate variable for the generics, but using an anonymous - // lifetime for a function's return value - ref_struct.generics.params.insert( - 0, - GenericParam::Lifetime(LifetimeDef::new(Lifetime::new("'rx", Span::call_site()))), - ); + ident: Option, + vis: Visibility, + ty: Type, + attrs: Vec, +} - match mid_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 { - let mid_wrapper_ty = Ident::new( - &(wrapper_ty.to_string() + "PerseusRxIntermediary"), - Span::call_site(), - ); - syn::Type::Verbatim(quote!(#mid_wrapper_ty)) - } else { - syn::Type::Verbatim(quote!(::sycamore::prelude::RcSignal<#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( - mid_struct, - "tuple structs can't be made reactive with this macro (try using named fields instead)", - ) - .to_compile_error(), - // We may well need a unit struct for templates that use global state but don't have proper - // state of their own We don't need to modify any fields - syn::Fields::Unit => (), +/// The underlying implementation of the `ReactiveState` derive macro, which +/// implements the traits involved in Perseus' reactive state platform, creating +/// an intermediary reactive struct using `RcSignal`s and a final one using +/// `&'cx Signal`s, where `cx` is a Sycamore scope lifetime. +pub fn make_rx_impl(input: ReactiveStateDeriveInput) -> TokenStream { + // Extract the fields of the `struct` + let fields = match input.data { + Data::Struct(fields) => fields.fields, + Data::Enum(_) => return syn::Error::new_spanned( + input.ident, + "you can only make `struct`s reactive with this macro (`enum` capability will be added in a future release, for now you'll have to implement it manually)" + ).to_compile_error(), }; - match ref_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 { - // If we don't make this a reference, nested properties have to be cloned (not - // nice for ergonomics) TODO Check back on this, could bite - // back! - syn::Type::Verbatim(quote!(&'rx #wrapper_ty<'rx>)) - } else { - // This is the only difference from the intermediate `struct` (this lifetime is - // declared above) - syn::Type::Verbatim(quote!(&'rx ::sycamore::prelude::RcSignal<#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; - } + // Now go through them and create what we want for both the intermediate and the + // reactive `struct`s + let mut intermediate_fields = quote!(); + let mut ref_fields = quote!(); + let mut intermediate_field_makers = quote!(); + let mut ref_field_makers = quote!(); // These start at the intermediate + let mut unrx_field_makers = quote!(); + for field in fields.iter() { + let old_ty = field.ty.to_token_stream(); + let field_ident = field.ident.as_ref().unwrap(); // It's a `struct`, so this is defined + let field_vis = &field.vis; + let mut field_attrs = quote!(); + for attr in field.attrs.iter() { + field_attrs.extend(attr.to_token_stream()); } - syn::Fields::Unnamed(_) => return syn::Error::new_spanned( - mid_struct, - "tuple structs can't be made reactive with this macro (try using named fields instead)", - ) - .to_compile_error(), - // We may well need a unit struct for templates that use global state but don't have proper - // state of their own We don't need to modify any fields - syn::Fields::Unit => (), - }; - // Create a list of fields for the `.make_rx()` method - let make_rx_fields = match mid_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::create_rc_signal(self.#field_name), - }); - } - } - quote! { - #mid_name { - #field_assignments - } - } + // Nested fields are left as-is, non-nested ones are wrapped in `RcSignal`s + if field.nested { + // Nested types should implement the necessary linking traits + intermediate_fields.extend(quote! { + #field_attrs + #field_vis #field_ident: <#old_ty as ::perseus::state::MakeRx>::Rx, + }); + ref_fields.extend(quote! { + #field_attrs + #field_vis #field_ident: <<#old_ty as ::perseus::state::MakeRx>::Rx as ::perseus::state::MakeRxRef>::RxRef<'__derived_rx>, + }); + intermediate_field_makers.extend(quote! { #field_ident: self.#field_ident.make_rx(), }); + ref_field_makers.extend(quote! { #field_ident: self.#field_ident.to_ref_struct(cx), }); + unrx_field_makers + .extend(quote! { #field_ident: self.#field_ident.clone().make_unrx(), }); + } else { + intermediate_fields.extend(quote! { + #field_attrs + #field_vis #field_ident: ::sycamore::prelude::RcSignal<#old_ty>, + }); + ref_fields.extend(quote! { + #field_attrs + #field_vis #field_ident: &'__derived_rx ::sycamore::prelude::RcSignal<#old_ty>, + }); + intermediate_field_makers.extend( + quote! { #field_ident: ::sycamore::prelude::create_rc_signal(self.#field_ident), }, + ); + ref_field_makers.extend( + quote! { #field_ident: ::sycamore::prelude::create_ref(cx, self.#field_ident), }, + ); + unrx_field_makers + .extend(quote! { #field_ident: (*self.#field_ident.get_untracked()).clone(), }); + // All fields must be `Clone` } - syn::Fields::Unit => quote!(#mid_name), - // We filtered out the other types before - _ => unreachable!(), + } + + let ReactiveStateDeriveInput { + ident, + vis, + attrs: attrs_vec, + alias, + .. + } = input; + let mut attrs = quote!(); + for attr in attrs_vec.iter() { + attrs.extend(attr.to_token_stream()); + } + let intermediate_ident = Ident::new( + &(ident.to_string() + "PerseusRxIntermediate"), + Span::call_site(), + ); + let ref_ident = Ident::new(&(ident.to_string() + "PerseusRxRef"), Span::call_site()); + + // Create a type alias for the final reactive version for convenience, if the + // user asked for one + let ref_alias = if let Some(alias) = alias { + // We use the full form for a cleaner expansion in IDEs + quote! { #vis type #alias<'__derived_rx> = <<#ident as ::perseus::state::MakeRx>::Rx as ::perseus::state::MakeRxRef>::RxRef<'__derived_rx>; } + } else { + quote!() }; - // Create a list of fields for turning the intermediary `struct` into one using - // scoped references - let make_ref_fields = match mid_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: ::sycamore::prelude::create_ref(cx, self.#field_name.to_ref_struct(cx)), - }) - } else { - // This will be used in a place in which the `cx` variable stores a reactive - // scope - field_assignments.extend(quote! { - #field_name: ::sycamore::prelude::create_ref(cx, self.#field_name), - }); - } - } - quote! { - #ref_name { - #field_assignments - } - } + + // TODO Generics support + quote! { + #attrs + #[derive(Clone)] + #vis struct #intermediate_ident { + #intermediate_fields } - syn::Fields::Unit => quote!(#ref_name), - // We filtered out the other types before - _ => unreachable!(), - }; - let make_unrx_fields = match orig_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.clone().make_unrx(), - }) - } else { - // We can `.clone()` the field because we implement `Clone` on both the new and - // the original `struct`s, meaning all fields must also be `Clone` - field_assignments.extend(quote! { - #field_name: (*self.#field_name.get_untracked()).clone(), - }); - } - } - quote! { - #orig_name { - #field_assignments - } - } + + #attrs + #vis struct #ref_ident<'__derived_rx> { + #ref_fields } - syn::Fields::Unit => quote!(#orig_name), - // 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, ::std::clone::Clone)] - #orig_struct - impl #generics ::perseus::state::MakeRx for #orig_name #generics { - type Rx = #mid_name #generics; - fn make_rx(self) -> #mid_name #generics { + impl ::perseus::state::MakeRx for #ident { + type Rx = #intermediate_ident; + fn make_rx(self) -> Self::Rx { use ::perseus::state::MakeRx; - #make_rx_fields + Self::Rx { + #intermediate_field_makers + } } } - #[derive(::std::clone::Clone)] - #mid_struct - impl #generics ::perseus::state::MakeUnrx for #mid_name #generics { - type Unrx = #orig_name #generics; - fn make_unrx(self) -> #orig_name #generics { + impl ::perseus::state::MakeUnrx for #intermediate_ident { + type Unrx = #ident; + fn make_unrx(self) -> Self::Unrx { use ::perseus::state::MakeUnrx; - #make_unrx_fields + Self::Unrx { + #unrx_field_makers + } } } - impl #generics ::perseus::state::Freeze for #mid_name #generics { + impl ::perseus::state::Freeze for #intermediate_ident { fn freeze(&self) -> ::std::string::String { use ::perseus::state::MakeUnrx; - let unrx = #make_unrx_fields; + let unrx = self.clone().make_unrx(); // TODO Is this `.unwrap()` safe? ::serde_json::to_string(&unrx).unwrap() } } - // TODO Generics - impl ::perseus::state::MakeRxRef for #mid_name { - type RxRef<'a> = #ref_name<'a>; - fn to_ref_struct<'a>(self, cx: ::sycamore::prelude::Scope<'a>) -> #ref_name<'a> { - #make_ref_fields + impl ::perseus::state::MakeRxRef for #intermediate_ident { + type RxRef<'__derived_rx> = #ref_ident<'__derived_rx>; + fn to_ref_struct<'__derived_rx>(self, cx: ::sycamore::prelude::Scope<'__derived_rx>) -> Self::RxRef<'__derived_rx> { + Self::RxRef { + #ref_field_makers + } } } - #[derive(::std::clone::Clone)] - #ref_struct - impl<'a> ::perseus::state::RxRef for #ref_name<'a> { - type RxNonRef = #mid_name; + impl<'__derived_rx> ::perseus::state::RxRef for #ref_ident<'__derived_rx> { + type RxNonRef = #intermediate_ident; } + + #ref_alias } + + // // Note: we create three `struct`s with this macro: the original, the new + // one // (with references), and the new one (intermediary without + // references, stored // in context) 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 // We won't create the final `struct` + // yet to avoid more operations than // necessary + // // Note that we leave this as whatever visibility the original state was + // to // avoid compiler errors (since it will be exposed as a + // trait-linked type // through the ref struct) + // let mut mid_struct = orig_struct.clone(); // This will use `RcSignal`s, + // and will be stored in context let ItemStruct { + // ident: orig_name, + // generics, + // .. + // } = orig_struct.clone(); + // // The name of the final reference `struct`'s type alias + // let ref_name = helpers.name.unwrap_or_else(|| + // Ident::new(&(orig_name.to_string() + "Rx"), Span::call_site())); // The + // intermediate struct shouldn't be easily accessible let mid_name = + // Ident::new( &(orig_name.to_string() + "PerseusRxIntermediary"), + // Span::call_site(), + // ); + // mid_struct.ident = mid_name.clone(); + // // 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 they're technically invalid syntax (grr.) + // When we come // across these fields, we'll run `.make_rx()` on them + // instead of naively // wrapping them in an `RcSignal` + // let nested_fields = mid_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::>>(); + // // 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 all the `struct`s + // let mut filtered_attrs = 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.push(attr.clone()); + // } + // } + // orig_struct.attrs = filtered_attrs.clone(); + // mid_struct.attrs = filtered_attrs; + + // // Now define the final `struct` that uses references + // let mut ref_struct = mid_struct.clone(); + // ref_struct.ident = ref_name.clone(); + // // Add the `'rx` lifetime to the generics + // // We also need a separate variable for the generics, but using an + // anonymous // lifetime for a function's return value + // ref_struct.generics.params.insert( + // 0, + // GenericParam::Lifetime(LifetimeDef::new(Lifetime::new("'rx", + // Span::call_site()))), ); + + // match mid_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 { let + // mid_wrapper_ty = Ident::new( + // &(wrapper_ty.to_string() + "PerseusRxIntermediary"), + // Span::call_site(), ); + // syn::Type::Verbatim(quote!(#mid_wrapper_ty)) + // } else { + // + // syn::Type::Verbatim(quote!(::sycamore::prelude::RcSignal<#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( + // mid_struct, + // "tuple structs can't be made reactive with this macro (try using + // named fields instead)", ) + // .to_compile_error(), + // // We may well need a unit struct for templates that use global state + // but don't have proper // state of their own We don't need to + // modify any fields syn::Fields::Unit => (), + // }; + // match ref_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 { // + // If we don't make this a reference, nested properties have to be cloned + // (not // nice for ergonomics) TODO Check back on this, + // could bite // back! + // syn::Type::Verbatim(quote!(&'rx #wrapper_ty<'rx>)) + // } else { + // // This is the only difference from the intermediate + // `struct` (this lifetime is // declared above) + // syn::Type::Verbatim(quote!(&'rx + // ::sycamore::prelude::RcSignal<#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( + // mid_struct, + // "tuple structs can't be made reactive with this macro (try using + // named fields instead)", ) + // .to_compile_error(), + // // We may well need a unit struct for templates that use global state + // but don't have proper // state of their own We don't need to + // modify any fields syn::Fields::Unit => (), + // }; + + // // Create a list of fields for the `.make_rx()` method + // let make_rx_fields = match mid_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::create_rc_signal(self.#field_name), + // }); } + // } + // quote! { + // #mid_name { + // #field_assignments + // } + // } + // } + // syn::Fields::Unit => quote!(#mid_name), + // // We filtered out the other types before + // _ => unreachable!(), + // }; + // // Create a list of fields for turning the intermediary `struct` into one + // using // scoped references + // let make_ref_fields = match mid_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: ::sycamore::prelude::create_ref(cx, + // self.#field_name.to_ref_struct(cx)), }) + // } else { + // // This will be used in a place in which the `cx` + // variable stores a reactive // scope + // field_assignments.extend(quote! { + // #field_name: ::sycamore::prelude::create_ref(cx, + // self.#field_name), }); + // } + // } + // quote! { + // #ref_name { + // #field_assignments + // } + // } + // } + // syn::Fields::Unit => quote!(#ref_name), + // // We filtered out the other types before + // _ => unreachable!(), + // }; + // let make_unrx_fields = match orig_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.clone().make_unrx(), + // }) + // } else { + // // We can `.clone()` the field because we implement + // `Clone` on both the new and // the original + // `struct`s, meaning all fields must also be `Clone` + // field_assignments.extend(quote! { #field_name: + // (*self.#field_name.get_untracked()).clone(), }); + // } + // } + // quote! { + // #orig_name { + // #field_assignments + // } + // } + // } + // syn::Fields::Unit => quote!(#orig_name), + // // 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, ::std::clone::Clone)] // #orig_struct + // impl #generics ::perseus::state::MakeRx for #orig_name #generics { + // type Rx = #mid_name #generics; + // fn make_rx(self) -> #mid_name #generics { + // use ::perseus::state::MakeRx; + // #make_rx_fields + // } + // } + // #[derive(::std::clone::Clone)] + // #mid_struct + // impl #generics ::perseus::state::MakeUnrx for #mid_name #generics { + // type Unrx = #orig_name #generics; + // fn make_unrx(self) -> #orig_name #generics { + // use ::perseus::state::MakeUnrx; + // #make_unrx_fields + // } + // } + // impl #generics ::perseus::state::Freeze for #mid_name #generics { + // fn freeze(&self) -> ::std::string::String { + // use ::perseus::state::MakeUnrx; + // let unrx = #make_unrx_fields; + // // TODO Is this `.unwrap()` safe? + // ::serde_json::to_string(&unrx).unwrap() + // } + // } + // // TODO Generics + // impl ::perseus::state::MakeRxRef for #mid_name { + // type RxRef<'a> = #ref_name<'a>; + // fn to_ref_struct<'a>(self, cx: ::sycamore::prelude::Scope<'a>) -> + // #ref_name<'a> { #make_ref_fields + // } + // } + // #[derive(::std::clone::Clone)] + // #ref_struct + // impl<'a> ::perseus::state::RxRef for #ref_name<'a> { + // type RxNonRef = #mid_name; + // } + // } } diff --git a/packages/perseus-macro/src/template.rs b/packages/perseus-macro/src/template.rs index bd01cfdb1f..560be14670 100644 --- a/packages/perseus-macro/src/template.rs +++ b/packages/perseus-macro/src/template.rs @@ -170,7 +170,7 @@ pub fn template_impl(input: TemplateFn, is_reactive: bool) -> TokenStream { // If they are, we'll use them (so state persists for templates across the whole app) let render_ctx = ::perseus::RenderCtx::from_ctx(cx); // The render context will automatically handle prioritizing frozen or active state for us for this page as long as we have a reactive state type, which we do! - // We need there to be no lifetimes in `rx_props_ty` here, since the lifetimes the user decalred are defined inside the above function, which we + // We need there to be no lifetimes in `rx_props_ty` here, since the lifetimes the user declared are defined inside the above function, which we // aren't inside! match render_ctx.get_active_or_frozen_page_state::<<#rx_props_ty as RxRef>::RxNonRef>(&props.path) { // If we navigated back to this page, and it's still in the PSS, the given state will be a dummy, but we don't need to worry because it's never checked if this evaluates @@ -221,7 +221,7 @@ pub fn template_impl(input: TemplateFn, is_reactive: bool) -> TokenStream { // If they are, we'll use them (so state persists for templates across the whole app) let render_ctx = ::perseus::RenderCtx::from_ctx(cx); // The render context will automatically handle prioritizing frozen or active state for us for this page as long as we have a reactive state type, which we do! - // We need there to be no lifetimes in `rx_props_ty` here, since the lifetimes the user decalred are defined inside the above function, which we + // We need there to be no lifetimes in `rx_props_ty` here, since the lifetimes the user declared are defined inside the above function, which we // aren't inside! // We're taking normal, unwrapped types, so we use the fact that anything implementing // `UnreactiveState` can be turned into `UnreactiveStateWrapper` reactively to manage this diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index d1574a9aa8..b8747bf451 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -80,8 +80,8 @@ pub type Request = HttpRequest<()>; #[cfg(feature = "macros")] pub use perseus_macro::{ amalgamate_states, browser, browser_main, build_paths, build_state, engine, engine_main, - global_build_state, head, main, main_export, make_rx, request_state, set_headers, - should_revalidate, template, template_rx, test, UnreactiveState, + global_build_state, head, main, main_export, request_state, set_headers, should_revalidate, + template, template_rx, test, ReactiveState, UnreactiveState, }; pub use sycamore::prelude::{DomNode, Html, HydrateNode, SsrNode}; pub use sycamore_router::{navigate, navigate_replace}; @@ -123,13 +123,13 @@ pub mod prelude { #[cfg(feature = "macros")] pub use crate::{ amalgamate_states, browser, browser_main, build_paths, build_state, engine, engine_main, - global_build_state, head, main, main_export, make_rx, request_state, set_headers, - should_revalidate, template, template_rx, test, UnreactiveState, + global_build_state, head, main, main_export, request_state, set_headers, should_revalidate, + template, template_rx, test, ReactiveState, UnreactiveState, }; - #[cfg(feature = "i18n")] - pub use crate::{link, t}; pub use crate::{ - ErrorCause, ErrorPages, GenericErrorWithCause, PerseusApp, PerseusRoot, RenderCtx, - RenderFnResult, RenderFnResultWithCause, Template, + blame_err, make_blamed_err, ErrorCause, ErrorPages, GenericErrorWithCause, PerseusApp, + PerseusRoot, RenderCtx, RenderFnResult, RenderFnResultWithCause, Request, Template, }; + #[cfg(any(feature = "translator-fluent", feature = "translator-lightweight"))] + pub use crate::{link, t}; } diff --git a/packages/perseus/src/router/get_initial_view.rs b/packages/perseus/src/router/get_initial_view.rs index 80c4200091..2ae38a5eba 100644 --- a/packages/perseus/src/router/get_initial_view.rs +++ b/packages/perseus/src/router/get_initial_view.rs @@ -153,7 +153,7 @@ pub(crate) fn get_initial_view( #[cfg(feature = "cache-initial-load")] { - // Cache the page's head in the PSS (getting it as realiably as we can) + // Cache the page's head in the PSS (getting it as reliably as we can) let head_str = get_head(); pss.add_head(&path, head_str); } @@ -325,7 +325,7 @@ fn get_translations() -> Option { /// comment (which prevents any JS that was loaded later from being included). /// This is not guaranteed to always get exactly the original head, but it's /// pretty good, and prevents unnecessary network requests, while enabling the -/// caching of initially laoded pages. +/// caching of initially loaded pages. #[cfg(feature = "cache-initial-load")] fn get_head() -> String { let document = web_sys::window().unwrap().document().unwrap(); diff --git a/packages/perseus/src/state/global_state.rs b/packages/perseus/src/state/global_state.rs index 15ae314941..f7417cbccd 100644 --- a/packages/perseus/src/state/global_state.rs +++ b/packages/perseus/src/state/global_state.rs @@ -121,7 +121,7 @@ impl Freeze for GlobalStateType { match &self { Self::Loaded(state) => state.freeze(), // There's no point in serializing state that was sent from the server, since we can - // easily get it again later (it definitionally hasn't changed) + // easily get it again later (it can't possibly have been changed on the browser-side) Self::Server(_) => "Server".to_string(), Self::None => "None".to_string(), } diff --git a/packages/perseus/src/state/page_state_store.rs b/packages/perseus/src/state/page_state_store.rs index e765f1bd2d..b9c4ba889c 100644 --- a/packages/perseus/src/state/page_state_store.rs +++ b/packages/perseus/src/state/page_state_store.rs @@ -43,7 +43,7 @@ pub struct PageStateStore { max_size: usize, /// A list of pages that will be kept in the store no matter what. This can /// be used to maintain the states of essential pages regardless of how - /// much the user has travelled through the site. The *vast* majority of + /// much the user has traveled through the site. The *vast* majority of /// use-cases for this would be better fulfilled by using global state, and /// this API is *highly* likely to be misused! If at all possible, use /// global state! @@ -53,7 +53,7 @@ pub struct PageStateStore { /// be globally preloaded; any pages that should only be preloaded for a /// specific route should be placed in `route_preloaded` instead. preloaded: Rc>>, - /// Pages that have been prelaoded for the current route, which should be + /// Pages that have been preloaded for the current route, which should be /// cleared on a route change. route_preloaded: Rc>>, }