use std::collections::BTreeSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::Poll;
use std::time::Duration;
use serde::Serialize;
use serde_json::{Map, Value};
use super::error::InertiaError;
use super::page::{PageMetadata, ScrollProp};
use super::request::{InertiaRequest, MergeIntent, PartialSelection};
pub(crate) type PropFuture =
Pin<Box<dyn Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>> + Send>>;
pub(crate) type SharedPropFuture =
Pin<Box<dyn Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>> + Send>>;
pub(crate) type Resolver = Arc<dyn Fn() -> PropFuture + Send + Sync>;
pub(crate) type SharedResolver = Arc<dyn Fn(&InertiaRequest) -> SharedPropFuture + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeStrategy {
Merge,
Prepend,
DeepMerge,
}
#[derive(Clone)]
pub struct Prop {
base: BaseProp,
merge: Option<MergeStrategy>,
merge_path: Option<Arc<str>>,
match_on: Option<Arc<str>>,
scroll: Option<ScrollProp>,
once: Option<OnceProp>,
rescue: bool,
}
#[derive(Clone)]
pub(crate) enum BaseProp {
Eager(SerializedValue),
Always(SerializedValue),
Lazy(Resolver),
Optional(Resolver),
Deferred {
resolver: Resolver,
group: Option<Arc<str>>,
},
}
#[derive(Debug, Clone, Default)]
pub struct OnceProp {
key: Option<Arc<str>>,
ttl: Option<Duration>,
}
impl OnceProp {
pub fn new() -> Self {
Self::default()
}
pub fn key(mut self, key: impl Into<Arc<str>>) -> Self {
self.key = Some(key.into());
self
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
fn resolved_key<'a>(&'a self, path: &'a str) -> &'a str {
self.key.as_deref().unwrap_or(path)
}
fn expires_at(&self, now_ms: u64) -> Option<u64> {
self.ttl.map(|ttl| {
let millis = u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
now_ms.saturating_add(millis)
})
}
}
#[derive(Clone)]
pub(crate) struct SerializedValue {
value: Value,
}
impl SerializedValue {
pub(crate) fn new(value: Value) -> Self {
SerializedValue { value }
}
pub(crate) fn resolve(&self) -> Result<Value, InertiaError> {
Ok(self.value.clone())
}
}
impl Prop {
fn from_base(base: BaseProp) -> Prop {
Prop {
base,
merge: None,
merge_path: None,
match_on: None,
scroll: None,
once: None,
rescue: false,
}
}
pub(crate) fn base(&self) -> &BaseProp {
&self.base
}
fn merge_key(&self, key: &str) -> String {
match self.merge_path.as_deref() {
Some(suffix) => format!("{key}.{suffix}"),
None => key.to_string(),
}
}
fn strategy_for(&self, intent: Option<MergeIntent>) -> Option<MergeStrategy> {
match (self.scroll.is_some(), intent) {
(true, Some(MergeIntent::Prepend)) => Some(MergeStrategy::Prepend),
(true, _) => Some(self.merge.unwrap_or(MergeStrategy::Merge)),
(false, _) => self.merge,
}
}
}
#[derive(Clone, Default)]
pub struct Props {
entries: Vec<(Arc<str>, Prop)>,
}
impl Props {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, key: impl Into<Arc<str>>, prop: Prop) -> Self {
self.entries.push((key.into(), prop));
self
}
pub fn errors(self, errors: impl Serialize) -> Self {
let value = serde_json::to_value(&errors).unwrap_or(Value::Null);
self.with(
"errors",
Prop::from_base(BaseProp::Always(SerializedValue::new(value))),
)
}
pub(crate) fn from_serialized(value: Value) -> Result<Self, InertiaError> {
let map = match value {
Value::Object(m) => m,
_ => return Err(InertiaError::PropsMustBeObject),
};
let mut props = Props::new();
for (key, val) in map {
props = props.with(
key,
Prop::from_base(BaseProp::Eager(SerializedValue::new(val))),
);
}
Ok(props)
}
pub(crate) fn into_entries(self) -> Vec<(Arc<str>, Prop)> {
self.entries
}
}
#[derive(Clone, Default)]
pub struct SharedProps {
entries: Vec<(Arc<str>, SharedProp)>,
}
#[derive(Clone)]
pub(crate) enum SharedProp {
Page(Prop),
Optional(SharedResolver),
}
impl SharedProps {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, key: impl Into<Arc<str>>, value: impl Serialize) -> Self {
let value = serde_json::to_value(&value).unwrap_or(Value::Null);
self.entries.push((
key.into(),
SharedProp::Page(Prop::from_base(BaseProp::Eager(SerializedValue::new(
value,
)))),
));
self
}
pub fn always(mut self, key: impl Into<Arc<str>>, value: impl Serialize) -> Self {
let value = serde_json::to_value(&value).unwrap_or(Value::Null);
self.entries.push((
key.into(),
SharedProp::Page(Prop::from_base(BaseProp::Always(SerializedValue::new(
value,
)))),
));
self
}
pub fn prop(mut self, key: impl Into<Arc<str>>, prop: Prop) -> Self {
self.entries.push((key.into(), SharedProp::Page(prop)));
self
}
pub fn optional<F, Fut>(mut self, key: impl Into<Arc<str>>, resolver: F) -> Self
where
F: Fn(&InertiaRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>>
+ Send
+ 'static,
{
let erased: SharedResolver = Arc::new(move |req| {
let fut = resolver(req);
Box::pin(fut) as SharedPropFuture
});
self.entries
.push((key.into(), SharedProp::Optional(erased)));
self
}
pub(crate) fn iter(&self) -> impl Iterator<Item = (&Arc<str>, &SharedProp)> {
self.entries.iter().map(|(k, v)| (k, v))
}
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub fn eager(value: impl Serialize) -> Prop {
let value = serde_json::to_value(&value).unwrap_or(Value::Null);
Prop::from_base(BaseProp::Eager(SerializedValue::new(value)))
}
pub fn always(value: impl Serialize) -> Prop {
let value = serde_json::to_value(&value).unwrap_or(Value::Null);
Prop::from_base(BaseProp::Always(SerializedValue::new(value)))
}
pub fn lazy<F, Fut>(resolver: F) -> Prop
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
{
let erased: Resolver = Arc::new(move || Box::pin(resolver()) as PropFuture);
Prop::from_base(BaseProp::Lazy(erased))
}
pub fn optional<F, Fut>(resolver: F) -> Prop
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
{
let erased: Resolver = Arc::new(move || Box::pin(resolver()) as PropFuture);
Prop::from_base(BaseProp::Optional(erased))
}
pub fn deferred<F, Fut>(resolver: F) -> Prop
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
{
let erased: Resolver = Arc::new(move || Box::pin(resolver()) as PropFuture);
Prop::from_base(BaseProp::Deferred {
resolver: erased,
group: None,
})
}
pub fn deferred_group<F, Fut>(group: impl Into<Arc<str>>, resolver: F) -> Prop
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value, Box<dyn std::error::Error + Send + Sync>>> + Send + 'static,
{
let erased: Resolver = Arc::new(move || Box::pin(resolver()) as PropFuture);
Prop::from_base(BaseProp::Deferred {
resolver: erased,
group: Some(group.into()),
})
}
pub fn merge(mut prop: Prop) -> Prop {
prop.merge = Some(MergeStrategy::Merge);
prop
}
pub fn prepend(mut prop: Prop) -> Prop {
prop.merge = Some(MergeStrategy::Prepend);
prop
}
pub fn deep_merge(mut prop: Prop) -> Prop {
prop.merge = Some(MergeStrategy::DeepMerge);
prop
}
pub fn merge_path(path: impl Into<Arc<str>>, mut prop: Prop) -> Prop {
prop.merge_path = Some(path.into());
prop
}
pub fn match_on(field: impl Into<Arc<str>>, mut prop: Prop) -> Prop {
prop.match_on = Some(field.into());
prop
}
pub fn infinite_scroll(scroll: ScrollProp, mut prop: Prop) -> Prop {
prop.scroll = Some(scroll);
prop
}
pub fn once(prop: Prop) -> Prop {
once_with(OnceProp::new(), prop)
}
pub fn once_with(spec: OnceProp, mut prop: Prop) -> Prop {
prop.once = Some(spec);
prop
}
pub fn rescue(mut prop: Prop) -> Prop {
prop.rescue = true;
prop
}
#[derive(Debug)]
pub(crate) struct Resolved {
pub props: Map<String, Value>,
pub metadata: PageMetadata,
}
struct ResolveContext<'a> {
is_full: bool,
partial: Option<&'a PartialSelection>,
reset_paths: BTreeSet<&'a str>,
intent: Option<MergeIntent>,
now_ms: u64,
}
impl ResolveContext<'_> {
fn included(&self, path: &str) -> bool {
included(path, self.partial)
}
}
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| u64::try_from(since.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
pub(crate) async fn resolve(
page: Props,
shared: &SharedProps,
request: &InertiaRequest,
component: &str,
) -> Result<Resolved, InertiaError> {
let partial = request.partial_for(component);
let context = ResolveContext {
is_full: partial.is_none(),
reset_paths: match partial.as_ref() {
Some(p) => p.reset.iter().map(|s| s.as_ref()).collect(),
None => BTreeSet::new(),
},
partial: partial.as_ref(),
intent: request.merge_intent(),
now_ms: now_millis(),
};
let page_entries = page.into_entries();
let page_keys: BTreeSet<&str> = page_entries.iter().map(|(k, _)| k.as_ref()).collect();
let mut plans: Vec<Planned<'_>> = Vec::new();
let mut resolvers: Vec<PropFuture> = Vec::new();
for (key, shared_prop) in shared.iter() {
if page_keys.contains(key.as_ref()) {
continue;
}
let mut planned = match shared_prop {
SharedProp::Page(prop) => plan_page(key, prop, request, &context, &mut resolvers),
SharedProp::Optional(resolver) => Planned {
key: key.as_ref(),
prop: None,
shared: false,
once_key: None,
withheld: false,
action: if context.is_full || !context.included(key) {
Action::Nothing
} else {
Action::Invoke {
at: park(&mut resolvers, (resolver)(request)),
rescue: false,
}
},
},
};
planned.shared = true;
plans.push(planned);
}
for (key, prop) in &page_entries {
plans.push(plan_page(key, prop, request, &context, &mut resolvers));
}
let mut props: Map<String, Value> = Map::new();
let mut metadata = PageMetadata::default();
let mut outcomes = resolve_together(resolvers).await;
for plan in plans {
let outcome = match plan.action {
Action::Invoke { at, .. } => outcomes[at].take(),
_ => None,
};
apply(plan, outcome, &context, &mut props, &mut metadata)?;
}
if !props.contains_key("errors") {
props.insert("errors".to_string(), Value::Object(Map::new()));
}
if let Some(bag) = request.error_bag() {
let errors = props
.remove("errors")
.unwrap_or_else(|| Value::Object(Map::new()));
let mut scoped = Map::new();
scoped.insert(bag.to_string(), errors);
props.insert("errors".to_string(), Value::Object(scoped));
}
Ok(Resolved { props, metadata })
}
fn top_level_key(key: &str) -> &str {
key.split('.').next().unwrap_or(key)
}
fn insert_nested(props: &mut Map<String, Value>, dotted: &str, value: Value) {
let mut segments = dotted.split('.');
let first = match segments.next() {
Some(s) => s,
None => return,
};
let rest: Vec<&str> = segments.collect();
if rest.is_empty() {
props.insert(first.to_string(), value);
return;
}
let entry = props
.entry(first.to_string())
.or_insert_with(|| Value::Object(Map::new()));
insert_nested_into(entry, &rest, value);
}
fn insert_nested_into(current: &mut Value, segments: &[&str], value: Value) {
if segments.is_empty() {
*current = value;
return;
}
let map = match current {
Value::Object(m) => m,
other => {
*other = Value::Object(Map::new());
let Value::Object(m) = other else {
return;
};
m
}
};
let key = segments[0];
if segments.len() == 1 {
map.insert(key.to_string(), value);
return;
}
let entry = map
.entry(key.to_string())
.or_insert_with(|| Value::Object(Map::new()));
insert_nested_into(entry, &segments[1..], value);
}
fn included(path: &str, partial: Option<&PartialSelection>) -> bool {
match partial {
None => true,
Some(p) => match_path_included(path, &p.only, &p.except),
}
}
fn match_path_included(path: &str, only: &[Arc<str>], except: &[Arc<str>]) -> bool {
let selected = only.is_empty()
|| only.iter().any(|o| {
path == o.as_ref()
|| path
.strip_prefix(o.as_ref())
.is_some_and(|r| r.starts_with('.'))
|| o.strip_prefix(path).is_some_and(|r| r.starts_with('.'))
});
selected
&& !except.iter().any(|e| {
path == e.as_ref()
|| path
.strip_prefix(e.as_ref())
.is_some_and(|r| r.starts_with('.'))
})
}
type ResolverOutcome = Result<Value, Box<dyn std::error::Error + Send + Sync>>;
struct Planned<'a> {
key: &'a str,
prop: Option<&'a Prop>,
shared: bool,
once_key: Option<String>,
withheld: bool,
action: Action<'a>,
}
enum Action<'a> {
Nothing,
Ready(Value),
Failed(InertiaError),
Invoke { at: usize, rescue: bool },
Announce { group: &'a str },
}
fn park(resolvers: &mut Vec<PropFuture>, future: PropFuture) -> usize {
resolvers.push(future);
resolvers.len() - 1
}
async fn resolve_together(resolvers: Vec<PropFuture>) -> Vec<Option<ResolverOutcome>> {
let mut pending: Vec<Option<PropFuture>> = resolvers.into_iter().map(Some).collect();
let mut outcomes: Vec<Option<ResolverOutcome>> = (0..pending.len()).map(|_| None).collect();
let mut remaining = pending.len();
std::future::poll_fn(move |cx| {
for (slot, parked) in pending.iter_mut().enumerate() {
let Some(future) = parked.as_mut() else {
continue;
};
if let Poll::Ready(outcome) = future.as_mut().poll(cx) {
outcomes[slot] = Some(outcome);
*parked = None;
remaining -= 1;
}
}
if remaining == 0 {
Poll::Ready(std::mem::take(&mut outcomes))
} else {
Poll::Pending
}
})
.await
}
fn plan_page<'a>(
key: &'a str,
prop: &'a Prop,
request: &InertiaRequest,
context: &ResolveContext<'_>,
resolvers: &mut Vec<PropFuture>,
) -> Planned<'a> {
let once_key = prop
.once
.as_ref()
.map(|spec| spec.resolved_key(key).to_string());
let withheld = once_key
.as_deref()
.is_some_and(|held| request.holds_once(held));
let ready = |result: Result<Value, InertiaError>| match result {
Ok(value) => Action::Ready(value),
Err(error) => Action::Failed(error),
};
let action = if withheld {
Action::Nothing
} else {
match prop.base() {
BaseProp::Eager(value) => {
if context.is_full || context.included(key) {
ready(
value
.resolve()
.map(|value| filter_nested_value(key, value, context.partial)),
)
} else {
Action::Nothing
}
}
BaseProp::Always(value) => ready(value.resolve()),
BaseProp::Lazy(resolver) => {
if context.is_full || context.included(key) {
Action::Invoke {
at: park(resolvers, (resolver)()),
rescue: prop.rescue,
}
} else {
Action::Nothing
}
}
BaseProp::Optional(resolver) => {
if !context.is_full && context.included(key) {
Action::Invoke {
at: park(resolvers, (resolver)()),
rescue: prop.rescue,
}
} else {
Action::Nothing
}
}
BaseProp::Deferred { resolver, group } => {
if context.is_full {
Action::Announce {
group: group.as_deref().unwrap_or("default"),
}
} else if context.included(key) {
Action::Invoke {
at: park(resolvers, (resolver)()),
rescue: prop.rescue,
}
} else {
Action::Nothing
}
}
}
};
Planned {
key,
prop: Some(prop),
shared: false,
once_key,
withheld,
action,
}
}
fn apply(
plan: Planned<'_>,
outcome: Option<ResolverOutcome>,
context: &ResolveContext<'_>,
props: &mut Map<String, Value>,
metadata: &mut PageMetadata,
) -> Result<(), InertiaError> {
let key = plan.key;
let included = match plan.action {
Action::Nothing => false,
Action::Ready(value) => {
insert_nested(props, key, value);
true
}
Action::Failed(error) => return Err(error),
Action::Announce { group } => {
metadata.record_deferred(group, key);
false
}
Action::Invoke { rescue, .. } => {
match outcome.expect("a planned resolver is awaited before it is applied") {
Ok(value) => {
insert_nested(props, key, value);
true
}
Err(_) if rescue => {
metadata.record_rescued(key);
false
}
Err(source) => {
return Err(InertiaError::PropResolution {
path: Arc::from(key),
source,
});
}
}
}
};
if let Some(prop) = plan.prop
&& let (Some(spec), Some(once_key)) = (prop.once.as_ref(), plan.once_key.as_deref())
&& (included || plan.withheld)
{
metadata.record_once(once_key, key, spec.expires_at(context.now_ms));
}
if included {
if let Some(prop) = plan.prop {
if let Some(scroll) = prop.scroll.as_ref() {
metadata.record_scroll(key, scroll.clone());
}
if !context.reset_paths.contains(key) {
let merge_key = prop.merge_key(key);
if let Some(strategy) = prop.strategy_for(context.intent) {
metadata.record_merge(strategy, &merge_key);
}
if let Some(field) = prop.match_on.as_deref() {
metadata.record_match_on(&format!("{merge_key}.{field}"));
}
}
}
if plan.shared {
metadata.record_shared(top_level_key(key));
}
}
Ok(())
}
fn filter_nested_value(path: &str, value: Value, partial: Option<&PartialSelection>) -> Value {
let Some(partial) = partial else {
return value;
};
let Value::Object(map) = value else {
return value;
};
let mut filtered = Map::new();
for (key, value) in map {
let child_path = format!("{path}.{key}");
if included(&child_path, Some(partial)) {
filtered.insert(key, filter_nested_value(&child_path, value, Some(partial)));
}
}
Value::Object(filtered)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
fn request(pairs: &[(&'static str, &str)]) -> InertiaRequest {
let mut headers = HeaderMap::new();
for (name, value) in pairs {
headers.insert(
HeaderName::from_static(name),
HeaderValue::from_str(value).expect("header value"),
);
}
InertiaRequest::parse(&headers, &Method::GET, &Uri::from_static("/posts"))
}
fn partial(component: &str, only: &str) -> InertiaRequest {
request(&[
("x-inertia", "true"),
("x-inertia-partial-component", component),
("x-inertia-partial-data", only),
])
}
async fn run(props: Props, request: &InertiaRequest) -> Resolved {
resolve(props, &SharedProps::new(), request, "posts/index")
.await
.expect("resolution succeeds")
}
fn metadata_json(resolved: &Resolved) -> Value {
serde_json::to_value(&resolved.metadata).expect("metadata serializes")
}
#[test]
fn match_path_only_empty_includes_all() {
assert!(match_path_included("users", &[], &[]));
}
#[test]
fn match_path_only_exact() {
let only = vec![Arc::from("users")];
assert!(match_path_included("users", &only, &[]));
assert!(!match_path_included("posts", &only, &[]));
}
#[test]
fn match_path_only_descendant() {
let only = vec![Arc::from("auth")];
assert!(match_path_included("auth.user", &only, &[]));
}
#[test]
fn match_path_except_removes() {
let only = vec![Arc::from("auth")];
let except = vec![Arc::from("auth.token")];
assert!(!match_path_included("auth.token", &only, &except));
}
#[tokio::test]
async fn a_once_prop_is_sent_and_announced_on_the_first_visit() {
let resolved = run(
Props::new().with(
"settings",
once(eager(serde_json::json!({ "theme": "dark" }))),
),
&request(&[]),
)
.await;
assert_eq!(resolved.props["settings"]["theme"], Value::from("dark"));
assert_eq!(
metadata_json(&resolved)["onceProps"]["settings"]["prop"],
Value::from("settings")
);
}
#[tokio::test]
async fn a_once_prop_the_client_holds_is_withheld_but_still_named() {
let resolved = run(
Props::new().with(
"settings",
once(eager(serde_json::json!({ "theme": "dark" }))),
),
&request(&[("x-inertia-except-once-props", "settings")]),
)
.await;
assert!(!resolved.props.contains_key("settings"));
assert_eq!(
metadata_json(&resolved)["onceProps"]["settings"]["prop"],
Value::from("settings")
);
}
#[tokio::test]
async fn a_once_prop_can_be_keyed_independently_of_its_path() {
let spec = OnceProp::new().key("settings-v2");
let resolved = run(
Props::new().with("settings", once_with(spec, eager(1))),
&request(&[("x-inertia-except-once-props", "settings")]),
)
.await;
assert_eq!(resolved.props["settings"], Value::from(1));
assert_eq!(
metadata_json(&resolved)["onceProps"]["settings-v2"]["prop"],
Value::from("settings")
);
}
#[tokio::test]
async fn a_once_ttl_becomes_a_deadline_ahead_of_now() {
let spec = OnceProp::new().ttl(Duration::from_secs(60));
let resolved = run(
Props::new().with("settings", once_with(spec, eager(1))),
&request(&[]),
)
.await;
let expires = metadata_json(&resolved)["onceProps"]["settings"]["expiresAt"]
.as_u64()
.expect("a millisecond epoch");
let now = now_millis();
assert!(expires > now, "{expires} should be after {now}");
assert!(expires <= now + 60_000);
}
#[tokio::test]
async fn a_once_prop_a_partial_reload_skips_is_not_announced() {
let resolved = run(
Props::new()
.with("settings", once(eager(1)))
.with("posts", eager(2)),
&partial("posts/index", "posts"),
)
.await;
assert!(!resolved.props.contains_key("settings"));
assert!(
metadata_json(&resolved).get("onceProps").is_none(),
"the prop was never in play, so there is nothing to say about it"
);
}
#[tokio::test]
async fn a_scroll_prop_appends_by_default_and_prepends_on_intent() {
let build = || {
Props::new().with(
"posts",
merge_path(
"data",
infinite_scroll(ScrollProp::new("page").current(2_i64).next(3_i64), eager(1)),
),
)
};
let appended = run(build(), &request(&[])).await;
let json = metadata_json(&appended);
assert_eq!(json["mergeProps"], serde_json::json!(["posts.data"]));
assert_eq!(json["scrollProps"]["posts"]["nextPage"], Value::from(3));
let prepended = run(
build(),
&request(&[("x-inertia-infinite-scroll-merge-intent", "prepend")]),
)
.await;
let json = metadata_json(&prepended);
assert_eq!(json["prependProps"], serde_json::json!(["posts.data"]));
assert!(json.get("mergeProps").is_none());
}
#[tokio::test]
async fn merge_intent_does_not_speak_for_props_without_scroll_state() {
let resolved = run(
Props::new().with("notifications", merge(eager(1))),
&request(&[("x-inertia-infinite-scroll-merge-intent", "prepend")]),
)
.await;
let json = metadata_json(&resolved);
assert_eq!(json["mergeProps"], serde_json::json!(["notifications"]));
assert!(json.get("prependProps").is_none());
}
#[tokio::test]
async fn match_on_names_the_array_path_and_the_identity_field() {
let resolved = run(
Props::new().with("posts", match_on("id", merge_path("data", merge(eager(1))))),
&request(&[]),
)
.await;
let json = metadata_json(&resolved);
assert_eq!(json["mergeProps"], serde_json::json!(["posts.data"]));
assert_eq!(json["matchPropsOn"], serde_json::json!(["posts.data.id"]));
}
#[tokio::test]
async fn a_reset_path_drops_the_merge_label_but_keeps_the_value() {
let resolved = resolve(
Props::new().with("posts", merge(eager(vec![1, 2]))),
&SharedProps::new(),
&request(&[
("x-inertia", "true"),
("x-inertia-partial-component", "posts/index"),
("x-inertia-partial-data", "posts"),
("x-inertia-reset", "posts"),
]),
"posts/index",
)
.await
.expect("resolution succeeds");
assert_eq!(resolved.props["posts"], serde_json::json!([1, 2]));
assert!(metadata_json(&resolved).get("mergeProps").is_none());
}
#[tokio::test]
async fn resolvers_run_at_the_same_time_rather_than_one_after_another() {
const STEP: Duration = Duration::from_millis(150);
const BOUND: Duration = Duration::from_millis(400);
let mut props = Props::new();
for index in 0..4 {
props = props.with(
format!("slow{index}"),
lazy(|| async {
tokio::time::sleep(STEP).await;
Ok(Value::from(1))
}),
);
}
let started = std::time::Instant::now();
let resolved = run(props, &request(&[])).await;
let elapsed = started.elapsed();
assert_eq!(resolved.props.len(), 5);
assert!(
elapsed < BOUND,
"four {STEP:?} resolvers took {elapsed:?}, which is one after another, not at once"
);
}
#[tokio::test]
async fn the_order_resolvers_finish_in_is_not_observable() {
async fn resolve_with(delays: [u64; 4]) -> Value {
fn sleeping(name: &'static str, delay: u64, fail: bool) -> Prop {
let prop = lazy(move || async move {
tokio::time::sleep(Duration::from_millis(delay)).await;
if fail {
Err("upstream is down".into())
} else {
Ok(Value::from(name))
}
});
if fail { rescue(prop) } else { prop }
}
let resolved = run(
Props::new()
.with("alpha", sleeping("alpha", delays[0], false))
.with("beta", sleeping("beta", delays[1], true))
.with("gamma", sleeping("gamma", delays[2], true))
.with("delta", sleeping("delta", delays[3], false)),
&request(&[]),
)
.await;
let metadata = metadata_json(&resolved);
serde_json::json!({ "metadata": metadata, "props": resolved.props })
}
let ascending = resolve_with([10, 20, 30, 40]).await;
let descending = resolve_with([40, 30, 20, 10]).await;
assert_eq!(ascending, descending);
assert_eq!(
ascending["metadata"]["rescuedProps"],
serde_json::json!(["beta", "gamma"])
);
}
#[tokio::test]
async fn a_rescued_resolver_failure_is_announced_instead_of_fatal() {
let resolved = run(
Props::new().with(
"suggestions",
rescue(lazy(|| async { Err("upstream is down".into()) })),
),
&request(&[]),
)
.await;
assert!(!resolved.props.contains_key("suggestions"));
assert_eq!(
metadata_json(&resolved)["rescuedProps"],
serde_json::json!(["suggestions"])
);
}
#[tokio::test]
async fn an_unrescued_resolver_failure_fails_the_render() {
let error = resolve(
Props::new().with("posts", lazy(|| async { Err("upstream is down".into()) })),
&SharedProps::new(),
&request(&[]),
"posts/index",
)
.await
.expect_err("the render must not silently lose a prop");
assert!(matches!(error, InertiaError::PropResolution { .. }));
}
#[tokio::test]
async fn a_deferred_prop_is_announced_first_and_resolved_on_follow_up() {
let announced = run(
Props::new().with(
"stats",
deferred_group("charts", || async { Ok(Value::from(7)) }),
),
&request(&[]),
)
.await;
assert!(!announced.props.contains_key("stats"));
assert_eq!(
metadata_json(&announced)["deferredProps"]["charts"],
serde_json::json!(["stats"])
);
let delivered = run(
Props::new().with(
"stats",
deferred_group("charts", || async { Ok(Value::from(7)) }),
),
&partial("posts/index", "stats"),
)
.await;
assert_eq!(delivered.props["stats"], Value::from(7));
}
#[tokio::test]
async fn errors_are_scoped_to_the_requested_bag() {
let resolved = run(
Props::new().errors(serde_json::json!({ "email": "is required" })),
&request(&[("x-inertia-error-bag", "createUser")]),
)
.await;
assert_eq!(
resolved.props["errors"],
serde_json::json!({ "createUser": { "email": "is required" } })
);
}
#[tokio::test]
async fn an_empty_error_bag_is_nested_too() {
let resolved = run(Props::new(), &request(&[("x-inertia-error-bag", "login")])).await;
assert_eq!(resolved.props["errors"], serde_json::json!({ "login": {} }));
}
#[tokio::test]
async fn errors_stay_flat_without_a_bag() {
let resolved = run(Props::new(), &request(&[])).await;
assert_eq!(resolved.props["errors"], serde_json::json!({}));
}
}