From 36e6e175d481c089c3011233c07369e6c8c3b7e6 Mon Sep 17 00:00:00 2001 From: Harings Rob Date: Tue, 1 Sep 2026 17:30:47 +0200 Subject: [PATCH 1/5] Add initial session props. --- README.md | 21 +++++++++--- src/adapters/axum/extractor.rs | 3 +- src/adapters/axum/layer.rs | 3 +- src/request.rs | 37 ++++++++++++++++++++- src/shared.rs | 15 ++++++++- tests/axum_integration.rs | 59 ++++++++++++++++++++++++++++++++-- 6 files changed, 127 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0b1c77f..1cc7fbe 100644 --- a/README.md +++ b/README.md @@ -217,16 +217,27 @@ inertia ```rust,ignore use veer::shared::shared_props_fn; +let app_context = app_context.clone(); let cfg = InertiaConfig::new() - .shared(shared_props_fn(|_req| async move { - serde_json::json!({ - "auth": { "user": current_user().await }, - "app": { "name": "Acme" }, - }) + .shared(shared_props_fn(move |req| { + let app_context = app_context.clone(); + let session = req.extension::().cloned(); + async move { + let user = match session { + Some(session) => current_user(&app_context, &session).await, + None => None, + }; + serde_json::json!({ + "auth": { "user": user }, + "app": { "name": "Acme" }, + }) + } })); ``` Shared props merge under per-response props (handler props win on key collision). +Request extensions installed by outer middleware, including a +`tower_sessions::Session`, are available through `RequestInfo::extension`. diff --git a/src/adapters/axum/extractor.rs b/src/adapters/axum/extractor.rs index 76e9459..1f85534 100644 --- a/src/adapters/axum/extractor.rs +++ b/src/adapters/axum/extractor.rs @@ -41,7 +41,8 @@ where .path_and_query() .map(|p| p.as_str().to_string()) .unwrap_or_else(|| parts.uri.path().to_string()); - let req_info = RequestInfo::from_parts(parts.method.clone(), url, &parts.headers); + let req_info = RequestInfo::from_parts(parts.method.clone(), url, &parts.headers) + .with_extensions((*per.req_extensions).clone()); Ok(Inertia::from_parts( per.config, req_info, diff --git a/src/adapters/axum/layer.rs b/src/adapters/axum/layer.rs index 99ce2f4..3270b6c 100644 --- a/src/adapters/axum/layer.rs +++ b/src/adapters/axum/layer.rs @@ -100,7 +100,8 @@ where method_for_post.clone(), url_for_finalize, &headers_clone, - ); + ) + .with_extensions((*extensions_snapshot).clone()); // Marker wraps Arc>>. Take the inner value. let inner_response = marker .0 diff --git a/src/request.rs b/src/request.rs index 7e65612..67c5833 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,6 +1,6 @@ //! Per-request data parsed from Inertia headers. -use http::{HeaderMap, Method}; +use http::{Extensions, HeaderMap, Method}; use std::collections::HashSet; /// Request information needed to drive the Inertia protocol. @@ -27,6 +27,7 @@ pub struct RequestInfo { pub partial_except: HashSet, /// Keys the client wants reset (clear merge state for these). pub reset: HashSet, + extensions: Extensions, } impl RequestInfo { @@ -70,9 +71,29 @@ impl RequestInfo { partial_only: split_csv(headers, &crate::headers::X_INERTIA_PARTIAL_DATA), partial_except: split_csv(headers, &crate::headers::X_INERTIA_PARTIAL_EXCEPT), reset: split_csv(headers, &crate::headers::X_INERTIA_RESET), + extensions: Extensions::new(), } } + /// Attach extensions installed by outer request middleware. + /// + /// Adapters use this to make request-scoped values such as sessions + /// available to shared prop resolvers. + pub fn with_extensions(mut self, extensions: Extensions) -> Self { + self.extensions = extensions; + self + } + + /// Return the request extensions installed by outer middleware. + pub fn extensions(&self) -> &Extensions { + &self.extensions + } + + /// Return a request extension by type. + pub fn extension(&self) -> Option<&T> { + self.extensions.get::() + } + /// Returns `true` if the request is a partial reload (component header set + only/except non-empty). pub fn is_partial(&self) -> bool { self.partial_component.is_some() @@ -143,4 +164,18 @@ mod tests { assert!(info.partial_except.contains("auth")); assert!(info.is_partial()); } + + #[test] + fn request_extensions_are_available() { + let mut extensions = Extensions::new(); + extensions.insert(String::from("session-user")); + + let info = RequestInfo::from_parts(Method::GET, "/".into(), &HeaderMap::new()) + .with_extensions(extensions); + + assert_eq!( + info.extension::().map(String::as_str), + Some("session-user") + ); + } } diff --git a/src/shared.rs b/src/shared.rs index 073a3fb..c48938d 100644 --- a/src/shared.rs +++ b/src/shared.rs @@ -7,12 +7,25 @@ use std::future::Future; use std::sync::Arc; /// Resolves shared props on each request. +/// +/// Middleware-installed request values, such as a `tower_sessions::Session`, +/// can be read through [`RequestInfo::extension`]. #[async_trait] pub trait SharedProps: Send + Sync { /// Produce the shared props for this request. async fn shared(&self, req: &RequestInfo) -> Value; } +#[async_trait] +impl

SharedProps for Arc

+where + P: SharedProps + ?Sized, +{ + async fn shared(&self, req: &RequestInfo) -> Value { + self.as_ref().shared(req).await + } +} + /// Adapter for `Fn(&RequestInfo) -> impl Future`. pub struct FnSharedProps(pub F); @@ -27,7 +40,7 @@ where } } -/// Helper to wrap a closure as a boxed `SharedProps` trait object. +/// Helper to wrap a closure as a boxed [`SharedProps`] resolver. pub fn shared_props_fn(f: F) -> Arc where F: Fn(&RequestInfo) -> Fut + Send + Sync + 'static, diff --git a/tests/axum_integration.rs b/tests/axum_integration.rs index 6774ce7..19a3e71 100644 --- a/tests/axum_integration.rs +++ b/tests/axum_integration.rs @@ -2,13 +2,15 @@ mod common; use axum::{ routing::{get, post}, - Router, + Extension, Router, }; use common::*; use http_body_util::BodyExt; use serde_json::json; use tower::ServiceExt; -use veer::{Inertia, InertiaConfig, InertiaLayer, MinimalRootView}; +#[cfg(feature = "tower-sessions")] +use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; +use veer::{shared::shared_props_fn, Inertia, InertiaConfig, InertiaLayer, MinimalRootView}; fn app(config: InertiaConfig) -> Router { Router::new() @@ -133,3 +135,56 @@ async fn csr_only_returns_json_for_plain_get() { "application/json" ); } + +#[tokio::test] +async fn shared_props_can_read_request_extensions() { + #[derive(Clone)] + struct SessionUser(&'static str); + + let cfg = InertiaConfig::new() + .version(|| "v1".into()) + .shared(shared_props_fn(|request| { + let user = request.extension::().map(|user| user.0); + async move { json!({"auth": {"user": user}}) } + })); + let app = app(cfg).layer(Extension(SessionUser("Ada"))); + + let response = app.oneshot(req_inertia("GET", "/", "v1")).await.unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let page: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(page["props"]["auth"]["user"], "Ada"); +} + +#[cfg(feature = "tower-sessions")] +#[tokio::test] +async fn shared_props_can_read_tower_session() { + let cfg = InertiaConfig::new() + .version(|| "v1".into()) + .shared(shared_props_fn(|request| { + let session = request.extension::().cloned(); + async move { + let user = match session { + Some(session) => session.get::("user").await.unwrap(), + None => None, + }; + json!({"auth": {"user": user}}) + } + })); + let app = Router::new() + .route( + "/", + get(|inertia: Inertia, session: Session| async move { + session.insert("user", "Ada").await.unwrap(); + inertia.render("Home", json!({"msg": "hello"})) + }), + ) + .layer(InertiaLayer::new(cfg)) + .layer(SessionManagerLayer::new(MemoryStore::default()).with_secure(false)); + + let response = app.oneshot(req_inertia("GET", "/", "v1")).await.unwrap(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let page: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(page["props"]["auth"]["user"], "Ada"); +} From b7aad2f9fb95dcaf39aa5e2290d63dcd6ca83fde Mon Sep 17 00:00:00 2001 From: Harings Rob Date: Thu, 3 Sep 2026 08:57:08 +0200 Subject: [PATCH 2/5] Add lazy shared props and multipart array fields Support client-cached once props, standalone TypeScript type registration, and named preconfigured method routers. Document the new shared prop and binding APIs. --- README.md | 26 +++++++++ src/adapters/axum/form.rs | 46 ++++++++++++++- src/adapters/axum/response.rs | 25 +++++++- src/adapters/axum/router.rs | 21 +++++-- src/bindings/mod.rs | 106 +++++++++++++++++++++++++++++++++- src/headers.rs | 3 + src/lib.rs | 2 +- src/page.rs | 11 ++++ src/props/closure.rs | 8 +++ src/props/resolver.rs | 96 +++++++++++++++++++++++++++++- src/request.rs | 3 + src/response.rs | 35 ++++++++++- src/shared.rs | 94 ++++++++++++++++++++++++++---- tests/axum_integration.rs | 51 ++++++++++++++++ tests/spec_coverage.rs | 63 ++++++++++++++++++++ 15 files changed, 565 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1cc7fbe..bae8814 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,21 @@ Shared props merge under per-response props (handler props win on key collision) Request extensions installed by outer middleware, including a `tower_sessions::Session`, are available through `RequestInfo::extension`. +Return `SharedPropsData` to attach shared props that are loaded only on demand: + +```rust,ignore +use veer::SharedPropsData; + +SharedPropsData::new(serde_json::json!({ "unread_count": unread_count })) + .lazy("notifications", move || async move { + serde_json::json!(load_notifications(&client).await) + }) +``` + +The lazy closure runs only when a matching partial reload explicitly requests +its key. Initial visits and unrelated reloads do not execute it. Page values +and page lazy/deferred props take precedence over shared lazy props. +

@@ -412,6 +427,17 @@ pub struct UsersIndexProps { veer::register_page!(UsersIndexProps, "Users/Index"); ``` +Register action payloads or other standalone frontend contracts separately: + +```rust,ignore +#[derive(serde::Deserialize, ts_rs::TS)] +struct UpdateProfileForm { + display_name: String, +} + +veer::register_type!(UpdateProfileForm); +``` + Then build your router using `veer::Router` — same fluent API as `axum::Router`, but every route gets a name and method so the codegen knows about it: ```rust,ignore diff --git a/src/adapters/axum/form.rs b/src/adapters/axum/form.rs index 5a15417..568377d 100644 --- a/src/adapters/axum/form.rs +++ b/src/adapters/axum/form.rs @@ -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, @@ -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, + 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)> { + 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 { diff --git a/src/adapters/axum/response.rs b/src/adapters/axum/response.rs index 3b8dffb..cf3bd4a 100644 --- a/src/adapters/axum/response.rs +++ b/src/adapters/axum/response.rs @@ -37,7 +37,7 @@ pub(crate) struct InertiaResponseMarker(pub Arc>>) /// Finish the response. Called by the layer with access to per-request state. pub(crate) async fn finalize( - builder: InertiaResponse, + mut builder: InertiaResponse, per: &PerRequest, req_info: &RequestInfo, ) -> Response { @@ -56,7 +56,26 @@ pub(crate) async fn finalize( // Resolve shared + base props. let shared = match &cfg.shared { - Some(s) => Some(s.shared(req_info).await), + Some(s) => { + let shared = s.shared(req_info).await; + for (key, prop) in shared.once { + if builder.base_props.get(&key).is_none() + && !builder.deferreds.contains_key(&key) + && !builder.lazies.contains_key(&key) + { + builder.once.entry(key).or_insert(prop); + } + } + for (key, lazy) in shared.lazies { + if builder.base_props.get(&key).is_none() + && !builder.deferreds.contains_key(&key) + && !builder.once.contains_key(&key) + { + builder.lazies.entry(key).or_insert(lazy); + } + } + Some(shared.value) + } None => None, }; @@ -90,6 +109,7 @@ pub(crate) async fn finalize( base: base_serialized, lazies: builder.lazies, deferreds: builder.deferreds, + once: builder.once, merges: builder.merges, shared: Some(SerializedBase { value: shared_value, @@ -109,6 +129,7 @@ pub(crate) async fn finalize( page.clear_history = builder.clear_history; page.merge_props = resolved.merge_props; page.deferred_props = resolved.deferred_props; + page.once_props = resolved.once_props; // Combine builder-attached reset keys with any the client signaled via // `X-Inertia-Reset`, deduped. let mut reset = builder.reset_merge_props.clone(); diff --git a/src/adapters/axum/router.rs b/src/adapters/axum/router.rs index f754858..10074a4 100644 --- a/src/adapters/axum/router.rs +++ b/src/adapters/axum/router.rs @@ -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( - mut self, + self, method: Method, name: &'static str, path: &'static str, @@ -143,13 +143,26 @@ where H: Handler, 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, + ) -> 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 { diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs index 0ddec98..5488b71 100644 --- a/src/bindings/mod.rs +++ b/src/bindings/mod.rs @@ -61,6 +61,8 @@ pub trait InertiaPageProps { /// Register a Rust prop struct as an Inertia page component for TS bindings. /// /// The struct must already derive `serde::Serialize` and `ts_rs::TS`. +/// Pass `shared = SharedProps` to compose the generated page contract with the +/// shared resolver's Rust type without repeating those fields in every page. /// /// # Example /// ```ignore @@ -76,6 +78,18 @@ 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) => { impl $crate::bindings::InertiaPageProps for $ty { const COMPONENT: &'static str = $component; @@ -90,6 +104,22 @@ macro_rules! register_page { }; } +/// 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. @@ -105,6 +135,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), +} + +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. @@ -135,6 +175,26 @@ pub fn collect_decls(out: &mut HashMap( + out: &mut HashMap, +) { + collect_decls::(out); + collect_decls::(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" { + S::ident() + } else { + format!("{} & {}", S::ident(), own_props) + }; + out.insert( + TypeId::of::(), + (T::ident(), format!("type {} = {};", T::ident(), contract)), + ); +} + /// Errors that can occur while emitting the bindings file. #[derive(Debug, thiserror::Error)] pub enum GenerateError { @@ -321,6 +381,9 @@ fn render_prop_decls_and_pages() -> String { (entry.collect_decls)(&mut decls); pages.insert(entry.component, (entry.ts_name)()); } + for entry in inventory::iter::() { + (entry.collect_decls)(&mut decls); + } let mut ordered: Vec<(String, String)> = decls .into_values() @@ -352,8 +415,7 @@ fn render_prop_decls_and_pages() -> String { s } -const HEADER: &str = "// This file is auto-generated by veer. Do not edit manually.\n\ -// Run `cargo run --bin gen-bindings` to regenerate.\n"; +const HEADER: &str = "// This file is auto-generated by veer. Do not edit manually.\n"; const PROTOCOL_TYPES: &str = r#" export interface PageObject

{ @@ -366,6 +428,7 @@ export interface PageObject

{ mergeProps?: string[]; resetMergeProps?: string[]; deferredProps?: Record; + onceProps?: Record; } export type ErrorBag = Record; @@ -375,3 +438,42 @@ export interface Flash { bags: Record; } "#; + +#[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::(&mut declarations); + assert_eq!( + declarations[&TypeId::of::()].1, + "type EmptyPage = Shared;" + ); + } + + #[test] + fn shared_page_contract_collects_both_types() { + let mut declarations = HashMap::new(); + collect_page_decls::(&mut declarations); + assert_eq!( + declarations[&TypeId::of::()].1, + "type Page = Shared & { title: string, };" + ); + assert!(declarations[&TypeId::of::()] + .1 + .contains("user: string")); + } +} diff --git a/src/headers.rs b/src/headers.rs index 03c41b3..596cf6c 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -18,5 +18,8 @@ pub const X_INERTIA_PARTIAL_EXCEPT: HeaderName = HeaderName::from_static("x-inertia-partial-except"); /// Indicates the client wants the server to reset specific merged props. pub const X_INERTIA_RESET: HeaderName = HeaderName::from_static("x-inertia-reset"); +/// Once-prop cache keys already held by the client. +pub const X_INERTIA_EXCEPT_ONCE_PROPS: HeaderName = + HeaderName::from_static("x-inertia-except-once-props"); /// Set on JSON responses so caches vary correctly. pub const VARY: HeaderName = HeaderName::from_static("vary"); diff --git a/src/lib.rs b/src/lib.rs index ca8645d..c4a64e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,7 +102,7 @@ pub use request::RequestInfo; pub use response::InertiaResponse; pub use root_view::{MinimalRootView, RootView, RootViewContext, ViteManifest, ViteRootView}; pub use session::{Flash, SessionStore}; -pub use shared::SharedProps; +pub use shared::{SharedProps, SharedPropsData}; pub use ssr::{SsrClient, SsrPayload}; #[cfg(feature = "axum")] diff --git a/src/page.rs b/src/page.rs index 05bb1e2..9521494 100644 --- a/src/page.rs +++ b/src/page.rs @@ -29,11 +29,21 @@ pub struct PageObject { /// Keys whose merge state the server is asking the client to reset. #[serde(rename = "resetMergeProps", skip_serializing_if = "Vec::is_empty")] pub reset_merge_props: Vec, + /// Client cache key to prop mapping for remembered values. + #[serde(rename = "onceProps", skip_serializing_if = "BTreeMap::is_empty")] + pub once_props: BTreeMap, /// Deferred props grouped by group name (Inertia v2+). #[serde(rename = "deferredProps", skip_serializing_if = "BTreeMap::is_empty")] pub deferred_props: BTreeMap>, } +/// Metadata identifying a value remembered by the Inertia client. +#[derive(Debug, Clone, Serialize)] +pub struct OncePropMetadata { + /// Name of the prop containing the remembered value. + pub prop: String, +} + fn is_false(b: &bool) -> bool { !*b } @@ -56,6 +66,7 @@ impl PageObject { merge_props: Vec::new(), reset_merge_props: Vec::new(), deferred_props: BTreeMap::new(), + once_props: BTreeMap::new(), } } } diff --git a/src/props/closure.rs b/src/props/closure.rs index 6f152c4..4446306 100644 --- a/src/props/closure.rs +++ b/src/props/closure.rs @@ -21,6 +21,14 @@ pub struct LazyProp { pub closure: LazyClosure, } +/// A prop remembered by the client under a stable cache key. +pub struct OnceProp { + /// Client cache key, which may differ from the prop name. + pub key: String, + /// Evaluated on the first visit and explicit partial refreshes. + pub closure: LazyClosure, +} + /// Deferred prop — first response advertises group; second response (when client asks) resolves. pub struct DeferredProp { /// Group key under which this deferred prop is advertised in the page object. diff --git a/src/props/resolver.rs b/src/props/resolver.rs index 43a9635..2987534 100644 --- a/src/props/resolver.rs +++ b/src/props/resolver.rs @@ -1,6 +1,6 @@ //! Resolve a serialized props tree against request partial-reload rules. -use crate::props::closure::{DeferredProp, LazyProp}; +use crate::props::closure::{DeferredProp, LazyProp, OnceProp}; use crate::request::RequestInfo; use serde::Serialize; use serde_json::{Map, Value}; @@ -12,6 +12,8 @@ pub struct ResolvedProps { pub props: Value, /// Keys to record under `page.mergeProps`. pub merge_props: Vec, + /// Client cache metadata, including already remembered values. + pub once_props: BTreeMap, /// Deferred groups → key list (only populated on first/non-partial responses). pub deferred_props: BTreeMap>, } @@ -26,6 +28,8 @@ pub struct ResolveInput<'a> { pub base: SerializedBase, /// Builder-attached lazy/optional props by top-level key (both routes share this map). pub lazies: HashMap, + /// Props remembered by the client. + pub once: HashMap, /// Builder-attached deferred props by top-level key. pub deferreds: HashMap, /// Builder-attached merge props by top-level key (already serialized). @@ -154,6 +158,7 @@ pub async fn resolve(input: ResolveInput<'_>) -> ResolvedProps { component, base, lazies, + once, deferreds, merges, shared, @@ -174,6 +179,25 @@ pub async fn resolve(input: ResolveInput<'_>) -> ResolvedProps { let mut deferred_groups: BTreeMap> = BTreeMap::new(); + let mut once_props = BTreeMap::new(); + for (key, prop) in once { + let explicitly_requested = partial_for_this_component && only.contains(&key); + let selected = !partial_for_this_component + || (only.is_empty() || explicitly_requested) && !except.contains(&key); + if selected { + once_props.insert( + prop.key.clone(), + crate::page::OncePropMetadata { prop: key.clone() }, + ); + } + let remembered = req.is_inertia && req.except_once_props.contains(&prop.key); + if selected && (!remembered || explicitly_requested) { + set_top(&mut tree, &key, (prop.closure)().await); + } else { + remove_top(&mut tree, &key); + } + } + for (key, prop) in lazies { let include = if partial_for_this_component { only.contains(&key) @@ -216,7 +240,7 @@ pub async fn resolve(input: ResolveInput<'_>) -> ResolvedProps { for k in keys { let path = format!("/{k}"); let is_always = always.contains(&path); - let allowed = is_always || only.contains(&k); + let allowed = is_always || only.is_empty() || only.contains(&k); let excluded = !is_always && except.contains(&k); if !allowed || excluded { map.remove(&k); @@ -242,6 +266,7 @@ pub async fn resolve(input: ResolveInput<'_>) -> ResolvedProps { props: tree, merge_props, deferred_props: deferred_groups, + once_props, } } @@ -314,6 +339,7 @@ mod tests { component: "Users/Index", base: empty_base(json!({"users": [1,2]})), lazies, + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: None, @@ -337,6 +363,7 @@ mod tests { component: "Users/Index", base: empty_base(json!({"users": [1,2]})), lazies, + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: None, @@ -362,6 +389,7 @@ mod tests { component: "Users/Index", base: empty_base(json!({"users": []})), lazies: HashMap::new(), + once: HashMap::new(), deferreds, merges: HashSet::new(), shared: None, @@ -390,6 +418,7 @@ mod tests { component: "Users/Index", base: empty_base(json!({"users": []})), lazies: HashMap::new(), + once: HashMap::new(), deferreds, merges: HashSet::new(), shared: None, @@ -408,6 +437,7 @@ mod tests { component: "X", base: empty_base(json!({"users": "base-wins"})), lazies: HashMap::new(), + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: Some(shared), @@ -429,6 +459,7 @@ mod tests { component: "X", base: empty_base(json!({"notifications": ["a"]})), lazies: HashMap::new(), + once: HashMap::new(), deferreds: HashMap::new(), merges, shared: None, @@ -465,6 +496,7 @@ mod tests { component: "Users/Index", base, lazies: HashMap::new(), + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: None, @@ -555,6 +587,7 @@ mod tests { component: "X", base, lazies: HashMap::new(), + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: None, @@ -578,6 +611,7 @@ mod tests { component: "Page", base, lazies: HashMap::new(), + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: None, @@ -597,6 +631,7 @@ mod tests { component: "Page", base: empty_base(json!({"a": 1, "b": 2, "c": 3})), lazies: HashMap::new(), + once: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), shared: None, @@ -605,4 +640,61 @@ mod tests { // a: in `only` → kept. b: in both → dropped (except wins). c: not in `only` → dropped. assert_eq!(resolved.props, json!({"a": 1})); } + #[tokio::test] + async fn once_props_resolve_only_when_the_client_needs_a_value() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + for (cached, requested, cache_key, expected_calls) in [ + (false, None, "org:one", 1), + (true, None, "org:one", 0), + (true, Some("organisation"), "org:one", 1), + (true, Some("other"), "org:one", 0), + (true, None, "org:two", 1), + ] { + let mut req = req_full(); + req.is_inertia = true; + if cached { + req.except_once_props.insert("org:one".into()); + } + if let Some(prop) = requested { + req.partial_component = Some("Page".into()); + req.partial_only.insert(prop.into()); + } + let calls = Arc::new(AtomicUsize::new(0)); + let resolver_calls = calls.clone(); + let shared = crate::SharedPropsData::new(json!({})).once_as( + "organisation", + cache_key, + move || async move { + resolver_calls.fetch_add(1, Ordering::SeqCst); + json!({"name": "Current organisation"}) + }, + ); + let result = resolve(ResolveInput { + req: &req, + component: "Page", + base: empty_base(json!({})), + once: shared.once, + lazies: HashMap::new(), + deferreds: HashMap::new(), + merges: HashSet::new(), + shared: None, + }) + .await; + assert_eq!(calls.load(Ordering::SeqCst), expected_calls); + assert_eq!( + result.props.get("organisation").is_some(), + expected_calls == 1 + ); + assert_eq!( + result.once_props.contains_key(cache_key), + requested != Some("other") + ); + if let Some(metadata) = result.once_props.get(cache_key) { + assert_eq!(metadata.prop, "organisation"); + } + } + } } diff --git a/src/request.rs b/src/request.rs index 67c5833..52d9198 100644 --- a/src/request.rs +++ b/src/request.rs @@ -27,6 +27,8 @@ pub struct RequestInfo { pub partial_except: HashSet, /// Keys the client wants reset (clear merge state for these). pub reset: HashSet, + /// Once-prop cache keys already available in the client. + pub except_once_props: HashSet, extensions: Extensions, } @@ -71,6 +73,7 @@ impl RequestInfo { partial_only: split_csv(headers, &crate::headers::X_INERTIA_PARTIAL_DATA), partial_except: split_csv(headers, &crate::headers::X_INERTIA_PARTIAL_EXCEPT), reset: split_csv(headers, &crate::headers::X_INERTIA_RESET), + except_once_props: split_csv(headers, &crate::headers::X_INERTIA_EXCEPT_ONCE_PROPS), extensions: Extensions::new(), } } diff --git a/src/response.rs b/src/response.rs index f4ba216..183c24b 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,6 +1,6 @@ //! The response value handlers return. -use crate::props::closure::{DeferredProp, LazyProp}; +use crate::props::closure::{DeferredProp, LazyProp, OnceProp}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::future::Future; @@ -9,6 +9,7 @@ use std::future::Future; pub struct InertiaResponse { pub(crate) component: String, pub(crate) base_props: Value, + pub(crate) once: HashMap, pub(crate) lazies: HashMap, pub(crate) deferreds: HashMap, pub(crate) merges: HashSet, @@ -25,6 +26,7 @@ impl InertiaResponse { Self { component: component.into(), base_props, + once: HashMap::new(), lazies: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), @@ -37,6 +39,37 @@ impl InertiaResponse { } } + /// Remember a prop across visits until the client explicitly requests it again. + pub fn once(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let key = key.into(); + self.once_as(key.clone(), key, f) + } + + /// Remember a prop under a custom key, for example one scoped to an organisation. + pub fn once_as( + mut self, + prop: impl Into, + cache_key: impl Into, + f: F, + ) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.once.insert( + prop.into(), + OnceProp { + key: cache_key.into(), + closure: Box::new(|| Box::pin(f())), + }, + ); + self + } + /// Attach a lazy prop (default-excluded; included only on partial reload that names it). /// /// Inertia v3 calls this concept "optional". `lazy()` is the preferred method name in diff --git a/src/shared.rs b/src/shared.rs index c48938d..8ab8eac 100644 --- a/src/shared.rs +++ b/src/shared.rs @@ -1,11 +1,83 @@ //! Shared props: per-config props merged into every response. +use crate::props::closure::{LazyProp, OnceProp}; use crate::request::RequestInfo; use async_trait::async_trait; use serde_json::Value; +use std::collections::HashMap; use std::future::Future; use std::sync::Arc; +/// Shared values and on-demand resolvers, merged underneath page props. +pub struct SharedPropsData { + pub(crate) value: Value, + pub(crate) once: HashMap, + pub(crate) lazies: HashMap, +} + +impl SharedPropsData { + /// Start with eagerly resolved shared values. + pub fn new(value: Value) -> Self { + Self { + value, + once: HashMap::new(), + lazies: HashMap::new(), + } + } + + /// Remember a prop across visits until the client explicitly requests it again. + pub fn once(self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + let key = key.into(); + self.once_as(key.clone(), key, f) + } + + /// Remember a prop under a custom key, for example one scoped to an organisation. + pub fn once_as( + mut self, + prop: impl Into, + cache_key: impl Into, + f: F, + ) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.once.insert( + prop.into(), + OnceProp { + key: cache_key.into(), + closure: Box::new(|| Box::pin(f())), + }, + ); + self + } + + /// Include this value only when explicitly requested by a partial reload. + pub fn lazy(mut self, key: impl Into, f: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + self.lazies.insert( + key.into(), + LazyProp { + closure: Box::new(|| Box::pin(f())), + }, + ); + self + } +} + +impl From for SharedPropsData { + fn from(value: Value) -> Self { + Self::new(value) + } +} + /// Resolves shared props on each request. /// /// Middleware-installed request values, such as a `tower_sessions::Session`, @@ -13,7 +85,7 @@ use std::sync::Arc; #[async_trait] pub trait SharedProps: Send + Sync { /// Produce the shared props for this request. - async fn shared(&self, req: &RequestInfo) -> Value; + async fn shared(&self, req: &RequestInfo) -> SharedPropsData; } #[async_trait] @@ -21,30 +93,32 @@ impl

SharedProps for Arc

where P: SharedProps + ?Sized, { - async fn shared(&self, req: &RequestInfo) -> Value { + async fn shared(&self, req: &RequestInfo) -> SharedPropsData { self.as_ref().shared(req).await } } -/// Adapter for `Fn(&RequestInfo) -> impl Future`. +/// Adapter for a resolver returning JSON values or [`SharedPropsData`]. pub struct FnSharedProps(pub F); #[async_trait] -impl SharedProps for FnSharedProps +impl SharedProps for FnSharedProps where F: Fn(&RequestInfo) -> Fut + Send + Sync, - Fut: Future + Send, + Fut: Future + Send, + R: Into + Send, { - async fn shared(&self, req: &RequestInfo) -> Value { - (self.0)(req).await + async fn shared(&self, req: &RequestInfo) -> SharedPropsData { + (self.0)(req).await.into() } } /// Helper to wrap a closure as a boxed [`SharedProps`] resolver. -pub fn shared_props_fn(f: F) -> Arc +pub fn shared_props_fn(f: F) -> Arc where F: Fn(&RequestInfo) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, + Fut: Future + Send + 'static, + R: Into + Send, { Arc::new(FnSharedProps(f)) } @@ -59,6 +133,6 @@ mod tests { async fn fn_shared_props_works() { let s = shared_props_fn(|_r| async { json!({"x": 1}) }); let r = RequestInfo::from_parts(http::Method::GET, "/".into(), &HeaderMap::new()); - assert_eq!(s.shared(&r).await, json!({"x": 1})); + assert_eq!(s.shared(&r).await.value, json!({"x": 1})); } } diff --git a/tests/axum_integration.rs b/tests/axum_integration.rs index 19a3e71..c784790 100644 --- a/tests/axum_integration.rs +++ b/tests/axum_integration.rs @@ -188,3 +188,54 @@ async fn shared_props_can_read_tower_session() { assert_eq!(page["props"]["auth"]["user"], "Ada"); } + +#[tokio::test] +async fn shared_lazy_props_execute_only_when_requested_and_page_props_win() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + let calls = Arc::new(AtomicUsize::new(0)); + let shared_calls = calls.clone(); + let cfg = InertiaConfig::new() + .version(|| "v1".into()) + .shared(shared_props_fn(move |_| { + let calls = shared_calls.clone(); + async move { + veer::SharedPropsData::new(json!({"count": 3})) + .lazy("notifications", move || async move { + calls.fetch_add(1, Ordering::SeqCst); + json!(["notice"]) + }) + .lazy("msg", || async { panic!("page values must win") }) + .lazy("stats", || async { panic!("page lazy values must win") }) + } + })); + let app = app(cfg); + for (path, component, only, expected_calls) in [ + ("/", "Home", "", 0), + ("/", "Home", "count", 0), + ("/", "WrongComponent", "notifications", 0), + ("/", "Home", "notifications", 1), + ("/", "Home", "msg", 1), + ("/with-lazy", "WithLazy", "stats", 1), + ] { + let mut request = req_inertia("GET", path, "v1"); + if !only.is_empty() { + request + .headers_mut() + .insert("x-inertia-partial-component", component.parse().unwrap()); + request + .headers_mut() + .insert("x-inertia-partial-data", only.parse().unwrap()); + } + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let page: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), expected_calls); + if component == "Home" && only == "notifications" { + assert_eq!(page["props"]["notifications"], json!(["notice"])); + } + } +} diff --git a/tests/spec_coverage.rs b/tests/spec_coverage.rs index 6226843..423c37b 100644 --- a/tests/spec_coverage.rs +++ b/tests/spec_coverage.rs @@ -671,6 +671,69 @@ mod multipart_tests { assert_eq!(page["props"]["uploaded_size"], 13); } + #[tokio::test] + async fn multipart_with_indexed_files_deserializes_into_vec() { + #[derive(Deserialize)] + struct UploadDocuments { + files: Vec, + } + + let cfg = InertiaConfig::new().version(|| "v1".into()); + let app = Router::new() + .route( + "/documents", + post( + |i: Inertia, InertiaForm(form): InertiaForm| async move { + i.render( + "documents/created", + json!({ + "filenames": form.files.into_iter().map(|file| file.filename).collect::>(), + }), + ) + }, + ), + ) + .layer(InertiaLayer::new(cfg)); + + let boundary = "veer-test-indexed-files"; + let body = multipart_body( + boundary, + &[ + Part::File { + name: "files[0]", + filename: "first.pdf", + content_type: "application/pdf", + bytes: b"first", + }, + Part::File { + name: "files[1]", + filename: "second.pdf", + content_type: "application/pdf", + bytes: b"second", + }, + ], + ); + let request = http::Request::builder() + .method("POST") + .uri("/documents") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .header("x-inertia", "true") + .header("x-inertia-version", "v1") + .body(axum::body::Body::from(body)) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), 200); + let page = body_json(response).await; + assert_eq!( + page["props"]["filenames"], + json!(["first.pdf", "second.pdf"]) + ); + } + #[tokio::test] async fn multipart_text_only_still_works() { // A multipart submission with no file fields should still deserialize From 3d4b3a06a7ad6874f9f5f0a6e44b0d4a3a951989 Mon Sep 17 00:00:00 2001 From: Harings Rob Date: Fri, 4 Sep 2026 08:21:26 +0200 Subject: [PATCH 3/5] Infinite scrolling config. --- README.md | 17 ++++++ src/adapters/axum/response.rs | 18 ++++++ src/bindings/mod.rs | 2 + src/headers.rs | 3 + src/lib.rs | 2 +- src/page.rs | 63 ++++++++++++++++++++ src/request.rs | 6 ++ src/response.rs | 12 ++++ tests/infinite_scroll.rs | 107 ++++++++++++++++++++++++++++++++++ 9 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/infinite_scroll.rs diff --git a/README.md b/README.md index bae8814..4b13bed 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,23 @@ Build modern single-page apps in React, Vue, or Svelte — without writing a JSO +### Infinite scrolling + +Return a paginated prop with a `data` array and mark it with scroll metadata: + +```rust +use veer::ScrollMetadata; + +inertia.render("Users/Index", serde_json::json!({ + "users": { "data": users, "total": total } +})).scroll("users", ScrollMetadata::new("page", page, previous_page, next_page)) +``` + +Wrap the items in the official `` client component. +Veer emits `scrollProps` and merges `users.data` in the requested direction. +Use the client's `reset: ['users']` visit option when changing filters. Database +filtering, counts, and page boundaries remain the application's responsibility. + ## 📖 Table of Contents - ✨ [What is Inertia, and why a Rust adapter](#-what-is-inertia-and-why-a-rust-adapter) diff --git a/src/adapters/axum/response.rs b/src/adapters/axum/response.rs index cf3bd4a..d77099b 100644 --- a/src/adapters/axum/response.rs +++ b/src/adapters/axum/response.rs @@ -141,6 +141,24 @@ pub(crate) async fn finalize( reset.sort(); page.reset_merge_props = reset; + for (key, mut metadata) in builder.scrolls { + if page.props.get(&key).is_none() { + continue; + } + metadata.reset = page.reset_merge_props.contains(&key); + if !metadata.reset { + let path = format!("{key}.data"); + if req_info.prepend_scroll { + page.prepend_props.push(path); + } else { + page.merge_props.push(path); + } + } + page.scroll_props.insert(key, metadata); + } + page.merge_props.sort(); + page.prepend_props.sort(); + // Build response based on decision. let response = match decision { ResponseShape::Json => { diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs index 5488b71..22ff393 100644 --- a/src/bindings/mod.rs +++ b/src/bindings/mod.rs @@ -426,6 +426,8 @@ export interface PageObject

{ encryptHistory?: boolean; clearHistory?: boolean; mergeProps?: string[]; + prependProps?: string[]; + scrollProps?: Record; resetMergeProps?: string[]; deferredProps?: Record; onceProps?: Record; diff --git a/src/headers.rs b/src/headers.rs index 596cf6c..302fedc 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -18,6 +18,9 @@ pub const X_INERTIA_PARTIAL_EXCEPT: HeaderName = HeaderName::from_static("x-inertia-partial-except"); /// Indicates the client wants the server to reset specific merged props. pub const X_INERTIA_RESET: HeaderName = HeaderName::from_static("x-inertia-reset"); +/// Whether an infinite-scroll response should append or prepend its items. +pub const X_INERTIA_INFINITE_SCROLL_MERGE_INTENT: HeaderName = + HeaderName::from_static("x-inertia-infinite-scroll-merge-intent"); /// Once-prop cache keys already held by the client. pub const X_INERTIA_EXCEPT_ONCE_PROPS: HeaderName = HeaderName::from_static("x-inertia-except-once-props"); diff --git a/src/lib.rs b/src/lib.rs index c4a64e7..1ed231a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,7 +96,7 @@ pub use config::InertiaConfig; pub use csrf::CsrfTokens; pub use error::VeerError; pub use inertia::Inertia; -pub use page::PageObject; +pub use page::{PageObject, ScrollMetadata, ScrollPage}; pub use props::{Always, Merge}; pub use request::RequestInfo; pub use response::InertiaResponse; diff --git a/src/page.rs b/src/page.rs index 9521494..a475c7f 100644 --- a/src/page.rs +++ b/src/page.rs @@ -26,6 +26,12 @@ pub struct PageObject { /// Keys whose values should merge into the client's existing prop state. #[serde(rename = "mergeProps", skip_serializing_if = "Vec::is_empty")] pub merge_props: Vec, + /// Array paths to prepend when loading an earlier scroll page. + #[serde(rename = "prependProps", skip_serializing_if = "Vec::is_empty")] + pub prepend_props: Vec, + /// Pagination metadata consumed by Inertia's InfiniteScroll component. + #[serde(rename = "scrollProps", skip_serializing_if = "BTreeMap::is_empty")] + pub scroll_props: BTreeMap, /// Keys whose merge state the server is asking the client to reset. #[serde(rename = "resetMergeProps", skip_serializing_if = "Vec::is_empty")] pub reset_merge_props: Vec, @@ -44,6 +50,61 @@ pub struct OncePropMetadata { pub prop: String, } +/// A numbered page or an opaque cursor for an infinite-scroll prop. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum ScrollPage { + /// A one-based page number. + Number(u32), + /// An opaque database cursor. + Cursor(String), +} + +impl From for ScrollPage { + fn from(value: u32) -> Self { + Self::Number(value) + } +} + +impl From for ScrollPage { + fn from(value: String) -> Self { + Self::Cursor(value) + } +} + +/// Page boundaries supplied by the application's database paginator. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrollMetadata { + /// Query-string parameter used to request a page. + pub page_name: String, + /// Page represented by this response. + pub current_page: ScrollPage, + /// Previous page, or `None` at the start. + pub previous_page: Option, + /// Next page, or `None` at the end. + pub next_page: Option, + pub(crate) reset: bool, +} + +impl ScrollMetadata { + /// Create metadata using either numbered pages or string cursors. + pub fn new>( + page_name: impl Into, + current_page: P, + previous_page: Option

, + next_page: Option

, + ) -> Self { + Self { + page_name: page_name.into(), + current_page: current_page.into(), + previous_page: previous_page.map(Into::into), + next_page: next_page.map(Into::into), + reset: false, + } + } +} + fn is_false(b: &bool) -> bool { !*b } @@ -64,6 +125,8 @@ impl PageObject { encrypt_history: false, clear_history: false, merge_props: Vec::new(), + prepend_props: Vec::new(), + scroll_props: BTreeMap::new(), reset_merge_props: Vec::new(), deferred_props: BTreeMap::new(), once_props: BTreeMap::new(), diff --git a/src/request.rs b/src/request.rs index 52d9198..d74008b 100644 --- a/src/request.rs +++ b/src/request.rs @@ -27,6 +27,8 @@ pub struct RequestInfo { pub partial_except: HashSet, /// Keys the client wants reset (clear merge state for these). pub reset: HashSet, + /// True when InfiniteScroll is loading an earlier page. + pub prepend_scroll: bool, /// Once-prop cache keys already available in the client. pub except_once_props: HashSet, extensions: Extensions, @@ -73,6 +75,10 @@ impl RequestInfo { partial_only: split_csv(headers, &crate::headers::X_INERTIA_PARTIAL_DATA), partial_except: split_csv(headers, &crate::headers::X_INERTIA_PARTIAL_EXCEPT), reset: split_csv(headers, &crate::headers::X_INERTIA_RESET), + prepend_scroll: headers + .get(&crate::headers::X_INERTIA_INFINITE_SCROLL_MERGE_INTENT) + .and_then(|value| value.to_str().ok()) + == Some("prepend"), except_once_props: split_csv(headers, &crate::headers::X_INERTIA_EXCEPT_ONCE_PROPS), extensions: Extensions::new(), } diff --git a/src/response.rs b/src/response.rs index 183c24b..4ab9417 100644 --- a/src/response.rs +++ b/src/response.rs @@ -13,6 +13,7 @@ pub struct InertiaResponse { pub(crate) lazies: HashMap, pub(crate) deferreds: HashMap, pub(crate) merges: HashSet, + pub(crate) scrolls: HashMap, pub(crate) encrypt_history: bool, pub(crate) clear_history: bool, pub(crate) reset_merge_props: Vec, @@ -30,6 +31,7 @@ impl InertiaResponse { lazies: HashMap::new(), deferreds: HashMap::new(), merges: HashSet::new(), + scrolls: HashMap::new(), encrypt_history: false, clear_history: false, reset_merge_props: Vec::new(), @@ -122,6 +124,16 @@ impl InertiaResponse { self } + /// Mark a paginated prop for Inertia's InfiniteScroll component. + /// + /// The prop must contain a `data` array. Its items are appended or prepended + /// according to the client's merge-intent header; other fields are replaced. + /// An `X-Inertia-Reset` request replaces the array and resets scroll state. + pub fn scroll(mut self, key: impl Into, metadata: crate::page::ScrollMetadata) -> Self { + self.scrolls.insert(key.into(), metadata); + self + } + /// Set `encryptHistory: true` in the page object (v2+ client primitive). pub fn encrypt_history(mut self) -> Self { self.encrypt_history = true; diff --git a/tests/infinite_scroll.rs b/tests/infinite_scroll.rs new file mode 100644 index 0000000..951f2c6 --- /dev/null +++ b/tests/infinite_scroll.rs @@ -0,0 +1,107 @@ +use axum::{body::Body, routing::get, Router}; +use http::{HeaderValue, Request}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::ServiceExt; +use veer::{Inertia, InertiaConfig, InertiaLayer, ScrollMetadata}; + +async fn response(headers: &[(&str, &str)]) -> Value { + let app = Router::new() + .route( + "/", + get(|inertia: Inertia| async move { + inertia + .render( + "Users", + json!({"users": {"data": [{"id": 3}], "total": 5}, "title": "Users"}), + ) + .scroll( + "users", + ScrollMetadata::new("page", 2_u32, Some(1), Some(3)), + ) + }), + ) + .layer(InertiaLayer::new( + InertiaConfig::new().version(|| "v1".into()), + )); + let mut request = Request::builder() + .uri("/") + .header("x-inertia", "true") + .header("x-inertia-version", "v1") + .body(Body::empty()) + .unwrap(); + for (name, value) in headers { + request.headers_mut().insert( + http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + let response = app.oneshot(request).await.unwrap(); + assert!(response.status().is_success()); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test] +async fn scroll_metadata_and_append_path_are_emitted() { + let page = response(&[]).await; + assert_eq!(page["mergeProps"], json!(["users.data"])); + assert_eq!( + page["scrollProps"]["users"], + json!({ + "pageName": "page", "currentPage": 2, "previousPage": 1, "nextPage": 3, "reset": false + }) + ); + assert_eq!(page["props"]["users"]["data"], json!([{"id": 3}])); +} + +#[tokio::test] +async fn scroll_previous_pages_prepend() { + let page = response(&[ + ("x-inertia-partial-component", "Users"), + ("x-inertia-partial-data", "users"), + ("x-inertia-infinite-scroll-merge-intent", "prepend"), + ]) + .await; + assert_eq!(page["prependProps"], json!(["users.data"])); + assert!(page.get("mergeProps").is_none()); +} + +#[tokio::test] +async fn resetting_scroll_replaces_data_instead_of_merging() { + let page = response(&[ + ("x-inertia-partial-component", "Users"), + ("x-inertia-partial-data", "users"), + ("x-inertia-reset", "users"), + ("x-inertia-infinite-scroll-merge-intent", "prepend"), + ]) + .await; + assert_eq!(page["scrollProps"]["users"]["reset"], true); + assert!(page.get("mergeProps").is_none()); + assert!(page.get("prependProps").is_none()); +} + +#[tokio::test] +async fn excluded_scroll_props_have_no_metadata_or_merge_path() { + let page = response(&[ + ("x-inertia-partial-component", "Users"), + ("x-inertia-partial-data", "title"), + ]) + .await; + assert!(page["props"].get("users").is_none()); + assert!(page.get("scrollProps").is_none()); + assert!(page.get("mergeProps").is_none()); +} + +#[test] +fn cursor_boundaries_serialize_as_strings_or_null() { + let metadata = ScrollMetadata::new( + "cursor", + "current".to_string(), + None, + Some("next".to_string()), + ); + let value = serde_json::to_value(metadata).unwrap(); + assert_eq!(value["currentPage"], "current"); + assert_eq!(value["nextPage"], "next"); + assert_eq!(value["previousPage"], Value::Null); +} From 22899c16332b5d9b5ffd8eca03a5e5a51f6a07a6 Mon Sep 17 00:00:00 2001 From: Harings Rob Date: Fri, 4 Sep 2026 08:22:41 +0200 Subject: [PATCH 4/5] readme update. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b13bed..de6cfd2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ inertia.render("Users/Index", serde_json::json!({ Wrap the items in the official `` client component. Veer emits `scrollProps` and merges `users.data` in the requested direction. -Use the client's `reset: ['users']` visit option when changing filters. Database +Use the client's `reset: ['users']` visit option when changing filters. Reset +visits are partial reloads, so include any other props that must refresh (such as +filters, counts, errors, or flash messages) in the visit's `only` option. Database filtering, counts, and page boundaries remain the application's responsibility. ## 📖 Table of Contents From ddd64d251aef54fa5e48de02bebf730b7e2a0c05 Mon Sep 17 00:00:00 2001 From: Harings Rob Date: Fri, 4 Sep 2026 09:16:03 +0200 Subject: [PATCH 5/5] MatchPropsOn --- README.md | 2 ++ src/adapters/axum/response.rs | 3 +++ src/bindings/mod.rs | 1 + src/page.rs | 13 +++++++++++++ tests/infinite_scroll.rs | 5 ++++- 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de6cfd2..fab3a41 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ inertia.render("Users/Index", serde_json::json!({ ``` Wrap the items in the official `` client component. +Use `ScrollMetadata::new(...).match_on("id")` when refreshed pages may contain +already-loaded items, such as a list that polls for status updates. Veer emits `scrollProps` and merges `users.data` in the requested direction. Use the client's `reset: ['users']` visit option when changing filters. Reset visits are partial reloads, so include any other props that must refresh (such as diff --git a/src/adapters/axum/response.rs b/src/adapters/axum/response.rs index d77099b..915dd2b 100644 --- a/src/adapters/axum/response.rs +++ b/src/adapters/axum/response.rs @@ -148,6 +148,9 @@ pub(crate) async fn finalize( metadata.reset = page.reset_merge_props.contains(&key); if !metadata.reset { let path = format!("{key}.data"); + if let Some(field) = &metadata.match_on { + page.match_props_on.push(format!("{path}.{field}")); + } if req_info.prepend_scroll { page.prepend_props.push(path); } else { diff --git a/src/bindings/mod.rs b/src/bindings/mod.rs index 22ff393..aa5c843 100644 --- a/src/bindings/mod.rs +++ b/src/bindings/mod.rs @@ -427,6 +427,7 @@ export interface PageObject

{ clearHistory?: boolean; mergeProps?: string[]; prependProps?: string[]; + matchPropsOn?: string[]; scrollProps?: Record; resetMergeProps?: string[]; deferredProps?: Record; diff --git a/src/page.rs b/src/page.rs index a475c7f..6cfd334 100644 --- a/src/page.rs +++ b/src/page.rs @@ -29,6 +29,9 @@ pub struct PageObject { /// Array paths to prepend when loading an earlier scroll page. #[serde(rename = "prependProps", skip_serializing_if = "Vec::is_empty")] pub prepend_props: Vec, + /// Item identity paths used to update matching items instead of duplicating them. + #[serde(rename = "matchPropsOn", skip_serializing_if = "Vec::is_empty")] + pub match_props_on: Vec, /// Pagination metadata consumed by Inertia's InfiniteScroll component. #[serde(rename = "scrollProps", skip_serializing_if = "BTreeMap::is_empty")] pub scroll_props: BTreeMap, @@ -85,6 +88,8 @@ pub struct ScrollMetadata { /// Next page, or `None` at the end. pub next_page: Option, pub(crate) reset: bool, + #[serde(skip)] + pub(crate) match_on: Option, } impl ScrollMetadata { @@ -101,8 +106,15 @@ impl ScrollMetadata { previous_page: previous_page.map(Into::into), next_page: next_page.map(Into::into), reset: false, + match_on: None, } } + + /// Match existing items by this field when merging refreshed scroll data. + pub fn match_on(mut self, field: impl Into) -> Self { + self.match_on = Some(field.into()); + self + } } fn is_false(b: &bool) -> bool { @@ -126,6 +138,7 @@ impl PageObject { clear_history: false, merge_props: Vec::new(), prepend_props: Vec::new(), + match_props_on: Vec::new(), scroll_props: BTreeMap::new(), reset_merge_props: Vec::new(), deferred_props: BTreeMap::new(), diff --git a/tests/infinite_scroll.rs b/tests/infinite_scroll.rs index 951f2c6..5d06f6b 100644 --- a/tests/infinite_scroll.rs +++ b/tests/infinite_scroll.rs @@ -17,7 +17,7 @@ async fn response(headers: &[(&str, &str)]) -> Value { ) .scroll( "users", - ScrollMetadata::new("page", 2_u32, Some(1), Some(3)), + ScrollMetadata::new("page", 2_u32, Some(1), Some(3)).match_on("id"), ) }), ) @@ -45,6 +45,7 @@ async fn response(headers: &[(&str, &str)]) -> Value { async fn scroll_metadata_and_append_path_are_emitted() { let page = response(&[]).await; assert_eq!(page["mergeProps"], json!(["users.data"])); + assert_eq!(page["matchPropsOn"], json!(["users.data.id"])); assert_eq!( page["scrollProps"]["users"], json!({ @@ -63,6 +64,7 @@ async fn scroll_previous_pages_prepend() { ]) .await; assert_eq!(page["prependProps"], json!(["users.data"])); + assert_eq!(page["matchPropsOn"], json!(["users.data.id"])); assert!(page.get("mergeProps").is_none()); } @@ -76,6 +78,7 @@ async fn resetting_scroll_replaces_data_instead_of_merging() { ]) .await; assert_eq!(page["scrollProps"]["users"]["reset"], true); + assert!(page.get("matchPropsOn").is_none()); assert!(page.get("mergeProps").is_none()); assert!(page.get("prependProps").is_none()); }