Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ tower-sessions = { version = "0.15", optional = true }
validator = { version = "0.21", features = ["derive"], optional = true }
garde = { version = "0.23", features = ["derive", "email"], optional = true }

ts-rs = { version = "12", optional = true }
ts-rs = { version = "11", optional = true }
inventory = { version = "0.3", optional = true }

[dev-dependencies]
Expand Down
23 changes: 23 additions & 0 deletions docs/props.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,26 @@ let cfg = InertiaConfig::new().preserve_big_integers(true);
```

Integers inside the safe range stay normal numbers, so the same prop can arrive as a `number` or a `bigint`. Flash data gets the same treatment.

## Application HTTP errors (fork)

Use `try_prop` and `try_optional` when the application's error implements Axum's
`IntoResponse`. Unlike `Prop::try_new`, these preserve its status, headers, and body.
Authorization of the route should run before declaring the response.

```rust,ignore
inertia.render("Estates/Show", json!({}))
.try_prop("estate", move || async move { load_overview(actor, id).await })
.try_optional("contact_picker", move || async move { search_contacts(actor, query).await })
```

`Prop::try_response(loader)` supplies the same behavior with composable modifiers.
`Prop::try_scroll(loader)` / `response.try_scroll(key, loader)` resolve the page
value and `ScrollMetadata` together. Neither the loader nor its metadata runs
when the prop is excluded. Optional props require an explicit `only` request;
an `except`-only request does not load them.

The fork retains `SharedPropsData`: its `.prop(key, Prop)` uses the same resolver
as page props. A page's explicit value or closure overrides a shared prop with
the same key. `.once_as()` and `.lazy()` are conveniences for scoped remembered
props and on-demand props.
14 changes: 5 additions & 9 deletions docs/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,12 @@ Flash data is now the top-level `flash` field of the page object. This is what t

No change on the Rust side: `with_flash` works as before.

## `ts-rs` 12
## TypeScript bindings in this fork

If you use the `ts` feature, update `ts-rs`:

```diff
- ts-rs = "11"
+ ts-rs = "12"
```

Then generate the bindings again. The generated `PageObject` and `Flash` types changed.
This fork retains `ts-rs` 11 to match its consuming application packages. It also
retains `register_page!(Props, "Page", shared = SharedProps)` and `register_type!`.
Upstream's ordinary closure props, composable `Prop`, and protocol changes are
used directly; the old scroll response helpers and `reset_merge` are removed.

## Route paths

Expand Down
2 changes: 1 addition & 1 deletion examples/axum-react-todo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ tower = "0.5"
tracing = "0.1"
tracing-subscriber = "0.3"
validator = { version = "0.21", features = ["derive"] }
ts-rs = "12"
ts-rs = "11"
46 changes: 43 additions & 3 deletions src/adapters/axum/form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ where
.await
.map_err(|e| InertiaFormRejection::MultipartTransport(e.to_string()))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
map.insert(
name,
insert_multipart_value(
&mut map,
&name,
serde_json::json!({
"__veer_uploaded_file__": true,
"filename": filename,
Expand All @@ -115,13 +116,52 @@ where
.text()
.await
.map_err(|e| InertiaFormRejection::MultipartTransport(e.to_string()))?;
map.insert(name, serde_json::Value::String(text));
insert_multipart_value(&mut map, &name, serde_json::Value::String(text));
}
}

Ok(serde_json::Value::Object(map))
}

#[cfg(feature = "multipart")]
fn insert_multipart_value(
map: &mut serde_json::Map<String, serde_json::Value>,
field_name: &str,
value: serde_json::Value,
) {
let Some((name, index)) = multipart_array_field(field_name) else {
map.insert(field_name.to_string(), value);
return;
};

let values = map
.entry(name.to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let serde_json::Value::Array(values) = values else {
return;
};

if let Some(index) = index {
values.resize(index + 1, serde_json::Value::Null);
values[index] = value;
} else {
values.push(value);
}
}

#[cfg(feature = "multipart")]
fn multipart_array_field(field_name: &str) -> Option<(&str, Option<usize>)> {
let (name, index) = field_name.rsplit_once('[')?;
let index = index.strip_suffix(']')?;
if name.is_empty() {
return None;
}
if index.is_empty() {
return Some((name, None));
}
Some((name, Some(index.parse().ok()?)))
}

/// Rejection from [`InertiaForm`]. Renders as a `4xx` HTTP response.
#[derive(Debug)]
pub enum InertiaFormRejection {
Expand Down
18 changes: 15 additions & 3 deletions src/adapters/axum/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,12 @@ pub(crate) async fn finalize(
}

// Shared props, plus the always-present `errors`.
let mut shared = match &cfg.shared {
Some(s) => serialize_tag_aware(&s.shared(req_info).await).unwrap_or_default(),
None => Default::default(),
let shared_data = match &cfg.shared {
Some(s) => s.shared(req_info).await,
None => crate::SharedPropsData::new(json!({})),
};
let shared_closures = shared_data.props;
let mut shared = serialize_tag_aware(&shared_data.value).unwrap_or_default();
if !shared.value.is_object() {
shared.value = Value::Object(Map::new());
}
Expand Down Expand Up @@ -139,6 +141,13 @@ pub(crate) async fn finalize(
Default::default()
});

for (key, prop) in shared_closures {
shared_keys.push(key.clone());
if !builder.props.contains_key(&key) && base.value.get(&key).is_none() {
builder.props.insert(key, prop);
}
}

// Shared once props. A handler prop with the same key wins.
for (key, f) in &cfg.shared_once {
shared_keys.push(key.clone());
Expand All @@ -164,6 +173,9 @@ pub(crate) async fn finalize(
let resolved = match resolved {
Ok(resolved) => resolved,
Err(error) => {
if let crate::props::prop::LoadError::Response(response) = error.error {
return failed(response, per).await;
}
// The error comes from application code at run time (a database
// error, for example), so it does not go in the body.
tracing::error!(%error, "veer: prop failed to resolve");
Expand Down
21 changes: 17 additions & 4 deletions src/adapters/axum/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ where
/// Same-path multi-method calls (e.g. GET and POST on `/todos`) are
/// merged into one [`MethodRouter`] — no extra ceremony required.
pub fn named_route<H, T>(
mut self,
self,
method: Method,
name: &'static str,
path: &'static str,
Expand All @@ -143,13 +143,26 @@ where
H: Handler<T, S>,
T: 'static,
{
let mr = method.into_router(handler);
self.named_method_router(method, name, path, method.into_router(handler))
}

/// Mount a preconfigured named method router and record it for TypeScript bindings.
///
/// This is useful when one generated route needs an Axum-specific setting such as
/// a larger request body limit.
pub fn named_method_router(
mut self,
method: Method,
name: &'static str,
path: &'static str,
method_router: MethodRouter<S>,
) -> Self {
match self.routes.swap_remove(path) {
Some(prev) => {
self.routes.insert(path, prev.merge(mr));
self.routes.insert(path, prev.merge(method_router));
}
None => {
self.routes.insert(path, mr);
self.routes.insert(path, method_router);
}
}
self.names.push(NamedRoute {
Expand Down
112 changes: 104 additions & 8 deletions src/bindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ pub trait InertiaPageProps {
/// ```
#[macro_export]
macro_rules! register_page {
($ty:ty, $component:literal, shared = $shared:ty) => {
impl $crate::bindings::InertiaPageProps for $ty {
const COMPONENT: &'static str = $component;
}
$crate::__private::inventory::submit! {
$crate::bindings::PageEntry {
component: $component,
ts_name: || <$ty as $crate::__private::ts_rs::TS>::ident(),
collect_decls: |out| $crate::bindings::collect_page_decls::<$ty, $shared>(out),
}
}
};

($ty:ty, $component:literal, $closure_ty:ty) => {
impl $crate::bindings::InertiaPageProps for $ty {
const COMPONENT: &'static str = $component;
Expand All @@ -101,11 +114,10 @@ macro_rules! register_page {
$crate::bindings::PageEntry {
component: $component,
ts_name: || {
let cfg = $crate::__private::ts_rs::Config::from_env();
format!(
"{} & {}",
<$ty as $crate::__private::ts_rs::TS>::ident(&cfg),
<$closure_ty as $crate::__private::ts_rs::TS>::ident(&cfg),
<$ty as $crate::__private::ts_rs::TS>::ident(),
<$closure_ty as $crate::__private::ts_rs::TS>::ident(),
)
},
collect_decls: |out| {
Expand All @@ -123,16 +135,30 @@ macro_rules! register_page {
$crate::bindings::PageEntry {
component: $component,
ts_name: || {
<$ty as $crate::__private::ts_rs::TS>::ident(
&$crate::__private::ts_rs::Config::from_env(),
)
<$ty as $crate::__private::ts_rs::TS>::ident()
},
collect_decls: |out| $crate::bindings::collect_decls::<$ty>(out),
}
}
};
}

/// Register a Rust type for TypeScript generation without associating it with
/// an Inertia page component.
///
/// Use this for typed action payloads and other frontend contracts that are
/// not reachable through a registered page's props.
#[macro_export]
macro_rules! register_type {
($ty:ty) => {
$crate::__private::inventory::submit! {
$crate::bindings::TypeEntry {
collect_decls: |out| $crate::bindings::collect_decls::<$ty>(out),
}
}
};
}

/// An entry submitted by `#[derive(InertiaPage)]` into the link-time registry.
///
/// You normally never construct this by hand.
Expand All @@ -148,6 +174,16 @@ pub struct PageEntry {

inventory::collect!(PageEntry);

/// A standalone TypeScript binding submitted through [`register_type!`](crate::register_type).
///
/// You normally never construct this by hand.
pub struct TypeEntry {
/// Populates the output with this type and its transitive dependencies.
pub collect_decls: fn(&mut HashMap<TypeId, (String, String)>),
}

inventory::collect!(TypeEntry);

/// Walk a TS-derived type and all its dependencies, populating `out` with
/// `(ts_name, decl)` pairs. Used internally by the derive-emitted
/// `collect_decls` closures.
Expand All @@ -160,8 +196,7 @@ pub fn collect_decls<T: ts_rs::TS + 'static + ?Sized>(out: &mut HashMap<TypeId,
return;
}
if T::output_path().is_some() {
let cfg = ts_rs::Config::from_env();
out.insert(id, (T::ident(&cfg), T::decl(&cfg)));
out.insert(id, (T::ident(), T::decl()));
} else {
// mark seen so we don't loop, but don't emit a decl
out.insert(id, (String::new(), String::new()));
Expand All @@ -179,6 +214,26 @@ pub fn collect_decls<T: ts_rs::TS + 'static + ?Sized>(out: &mut HashMap<TypeId,
T::visit_dependencies(&mut Visit { out });
}

/// Collect a page contract composed of its own props and the configured shared props.
pub fn collect_page_decls<T: ts_rs::TS + 'static, S: ts_rs::TS + 'static>(
out: &mut HashMap<TypeId, (String, String)>,
) {
collect_decls::<T>(out);
collect_decls::<S>(out);
let own_props = T::inline();
// ts-rs uses an empty index signature for empty Rust structs. Intersecting
// that with shared fields would forbid those fields and break Vue's compiler.
let contract = if own_props == "Record<string, never>" {
S::ident()
} else {
format!("{} & {}", S::ident(), own_props)
};
out.insert(
TypeId::of::<T>(),
(T::ident(), format!("type {} = {};", T::ident(), contract)),
);
}

/// Errors that can occur while emitting the bindings file.
#[derive(Debug, thiserror::Error)]
pub enum GenerateError {
Expand Down Expand Up @@ -405,6 +460,9 @@ fn render_prop_decls_and_pages() -> String {
pages.insert(entry.component, (entry.ts_name)());
}

for entry in inventory::iter::<TypeEntry>() {
(entry.collect_decls)(&mut decls);
}
let mut ordered: Vec<(String, String)> = decls
.into_values()
.filter(|(name, _)| !name.is_empty())
Expand Down Expand Up @@ -512,3 +570,41 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
}
#[cfg(test)]
mod shared_contract_tests {
use super::*;
#[derive(serde::Serialize, ts_rs::TS)]
pub struct Shared {
pub user: String,
}
#[derive(serde::Serialize, ts_rs::TS)]
pub struct Page {
pub title: String,
}

#[derive(serde::Serialize, ts_rs::TS)]
struct EmptyPage {}

#[test]
fn shared_only_page_has_no_empty_index_signature() {
let mut declarations = HashMap::new();
collect_page_decls::<EmptyPage, Shared>(&mut declarations);
assert_eq!(
declarations[&TypeId::of::<EmptyPage>()].1,
"type EmptyPage = Shared;"
);
}

#[test]
fn shared_page_contract_collects_both_types() {
let mut declarations = HashMap::new();
collect_page_decls::<Page, Shared>(&mut declarations);
assert_eq!(
declarations[&TypeId::of::<Page>()].1,
"type Page = Shared & { title: string, };"
);
assert!(declarations[&TypeId::of::<Shared>()]
.1
.contains("user: string"));
}
}
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,6 @@ mod tests {
let req = RequestInfo::from_parts(http::Method::GET, "/".into(), &http::HeaderMap::new())
.with_extensions(Arc::new(extensions));
let shared = c.shared.unwrap().shared(&req).await;
assert_eq!(shared, serde_json::json!({ "user": "ada" }));
assert_eq!(shared.value, serde_json::json!({ "user": "ada" }));
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pub use root_view::{
MinimalRootView, RootView, RootViewContext, ViteManifest, ViteManifestError, ViteRootView,
};
pub use session::{Flash, SessionStore};
pub use shared::SharedProps;
pub use shared::{SharedProps, SharedPropsData};
pub use ssr::{SsrClient, SsrPayload};

#[cfg(feature = "axum")]
Expand Down
Loading