use crate::props::closure::{DeferredProp, LazyProp};
use crate::request::RequestInfo;
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
pub struct ResolvedProps {
pub props: Value,
pub merge_props: Vec<String>,
pub deferred_props: BTreeMap<String, Vec<String>>,
}
pub struct ResolveInput<'a> {
pub req: &'a RequestInfo,
pub component: &'a str,
pub base: SerializedBase,
pub lazies: HashMap<String, LazyProp>,
pub deferreds: HashMap<String, DeferredProp>,
pub merges: HashSet<String>,
pub shared: Option<SerializedBase>,
}
pub struct SerializedBase {
pub value: Value,
pub always_paths: HashSet<String>,
pub merge_paths: HashSet<String>,
}
pub fn serialize_tag_aware<T: Serialize>(value: &T) -> Result<SerializedBase, serde_json::Error> {
let mut json_value = serde_json::to_value(value)?;
let mut always_paths = HashSet::new();
let mut merge_paths = HashSet::new();
let mut path = String::new();
strip_sentinels(
&mut json_value,
&mut path,
&mut always_paths,
&mut merge_paths,
);
Ok(SerializedBase {
value: json_value,
always_paths,
merge_paths,
})
}
fn strip_sentinels(
value: &mut Value,
path: &mut String,
always_paths: &mut HashSet<String>,
merge_paths: &mut HashSet<String>,
) {
use crate::props::{ALWAYS_SENTINEL, MERGE_SENTINEL};
loop {
let kind = match value.as_object() {
Some(map) if map.len() == 1 => {
if map.contains_key(ALWAYS_SENTINEL) {
Some(true)
} else if map.contains_key(MERGE_SENTINEL) {
Some(false)
} else {
None
}
}
_ => None,
};
let Some(is_always) = kind else { break };
if is_always {
always_paths.insert(path.clone());
} else {
merge_paths.insert(path.clone());
}
if let Value::Object(map) = std::mem::take(value) {
if let Some((_, inner)) = map.into_iter().next() {
*value = inner;
}
}
}
match value {
Value::Object(map) => {
for (k, v) in map.iter_mut() {
let prev_len = path.len();
path.push('/');
escape_pointer_segment(path, k);
strip_sentinels(v, path, always_paths, merge_paths);
path.truncate(prev_len);
}
}
Value::Array(arr) => {
for (i, v) in arr.iter_mut().enumerate() {
let prev_len = path.len();
use std::fmt::Write;
let _ = write!(path, "/{i}");
strip_sentinels(v, path, always_paths, merge_paths);
path.truncate(prev_len);
}
}
_ => {}
}
}
fn escape_pointer_segment(buf: &mut String, segment: &str) {
for ch in segment.chars() {
match ch {
'~' => buf.push_str("~0"),
'/' => buf.push_str("~1"),
c => buf.push(c),
}
}
}
pub async fn resolve(input: ResolveInput<'_>) -> ResolvedProps {
let ResolveInput {
req,
component,
base,
lazies,
deferreds,
merges,
shared,
} = input;
let mut tree = match shared {
Some(s) => merge_objects(s.value, base.value),
None => base.value,
};
let partial_for_this_component =
req.is_partial() && req.partial_component.as_deref() == Some(component);
let only: &HashSet<String> = &req.partial_only;
let except: &HashSet<String> = &req.partial_except;
let mut deferred_groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (key, prop) in lazies {
let include = if partial_for_this_component {
only.contains(&key)
} else {
false };
if include && !except.contains(&key) {
let v = (prop.closure)().await;
set_top(&mut tree, &key, v);
} else {
remove_top(&mut tree, &key);
}
}
for (key, prop) in deferreds {
if partial_for_this_component && only.contains(&key) {
let v = (prop.closure)().await;
set_top(&mut tree, &key, v);
} else if !partial_for_this_component {
deferred_groups
.entry(prop.group.to_string())
.or_default()
.push(key.clone());
remove_top(&mut tree, &key); } else {
remove_top(&mut tree, &key);
}
}
if partial_for_this_component {
let always = &base.always_paths;
if let Value::Object(map) = &mut tree {
let keys: Vec<String> = map.keys().cloned().collect();
for k in keys {
let path = format!("/{k}");
let is_always = always.contains(&path);
let allowed = is_always || only.contains(&k);
let excluded = !is_always && except.contains(&k);
if !allowed || excluded {
map.remove(&k);
}
}
}
}
let mut merge_props: Vec<String> = merges.into_iter().collect();
for path in &base.merge_paths {
if let Some(k) = path.strip_prefix('/') {
if !k.contains('/') {
merge_props.push(k.to_string());
}
}
}
merge_props.sort();
merge_props.dedup();
ResolvedProps {
props: tree,
merge_props,
deferred_props: deferred_groups,
}
}
fn merge_objects(mut a: Value, b: Value) -> Value {
match (&mut a, b) {
(Value::Object(ao), Value::Object(bo)) => {
for (k, v) in bo {
ao.insert(k, v);
}
a
}
(_, b) => b,
}
}
fn set_top(tree: &mut Value, key: &str, v: Value) {
if !tree.is_object() {
*tree = Value::Object(Map::new());
}
if let Value::Object(map) = tree {
map.insert(key.to_string(), v);
}
}
fn remove_top(tree: &mut Value, key: &str) {
if let Value::Object(map) = tree {
map.remove(key);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::props::closure::{DeferredProp, LazyProp};
use serde_json::json;
fn empty_base(v: Value) -> SerializedBase {
SerializedBase {
value: v,
always_paths: HashSet::new(),
merge_paths: HashSet::new(),
}
}
fn req_full() -> RequestInfo {
RequestInfo::from_parts(http::Method::GET, "/".into(), &http::HeaderMap::new())
}
fn req_partial(component: &str, only: &[&str], except: &[&str]) -> RequestInfo {
let mut r = req_full();
r.is_inertia = true;
r.partial_component = Some(component.into());
r.partial_only = only.iter().map(|s| s.to_string()).collect();
r.partial_except = except.iter().map(|s| s.to_string()).collect();
r
}
#[tokio::test]
async fn full_request_excludes_lazy() {
let req = req_full();
let mut lazies = HashMap::new();
lazies.insert(
"stats".into(),
LazyProp {
closure: Box::new(|| Box::pin(async { json!({"hits": 1}) })),
},
);
let resolved = resolve(ResolveInput {
req: &req,
component: "Users/Index",
base: empty_base(json!({"users": [1,2]})),
lazies,
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"users": [1,2]}));
}
#[tokio::test]
async fn partial_request_resolves_only_requested_lazy() {
let req = req_partial("Users/Index", &["stats"], &[]);
let mut lazies = HashMap::new();
lazies.insert(
"stats".into(),
LazyProp {
closure: Box::new(|| Box::pin(async { json!({"hits": 1}) })),
},
);
let resolved = resolve(ResolveInput {
req: &req,
component: "Users/Index",
base: empty_base(json!({"users": [1,2]})),
lazies,
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"stats": {"hits": 1}}));
}
#[tokio::test]
async fn deferred_first_pass_advertises_group_drops_value() {
let req = req_full();
let mut deferreds = HashMap::new();
deferreds.insert(
"expensive".into(),
DeferredProp {
group: "dashboard",
closure: Box::new(|| Box::pin(async { json!("never") })),
},
);
let resolved = resolve(ResolveInput {
req: &req,
component: "Users/Index",
base: empty_base(json!({"users": []})),
lazies: HashMap::new(),
deferreds,
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"users": []}));
assert_eq!(
resolved.deferred_props.get("dashboard"),
Some(&vec!["expensive".to_string()])
);
}
#[tokio::test]
async fn deferred_second_pass_resolves() {
let req = req_partial("Users/Index", &["expensive"], &[]);
let mut deferreds = HashMap::new();
deferreds.insert(
"expensive".into(),
DeferredProp {
group: "dashboard",
closure: Box::new(|| Box::pin(async { json!(42) })),
},
);
let resolved = resolve(ResolveInput {
req: &req,
component: "Users/Index",
base: empty_base(json!({"users": []})),
lazies: HashMap::new(),
deferreds,
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"expensive": 42}));
assert!(resolved.deferred_props.is_empty());
}
#[tokio::test]
async fn shared_props_merge_under_base() {
let req = req_full();
let shared = empty_base(json!({"app_name": "Acme", "users": "shared-wins-no"}));
let resolved = resolve(ResolveInput {
req: &req,
component: "X",
base: empty_base(json!({"users": "base-wins"})),
lazies: HashMap::new(),
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: Some(shared),
})
.await;
assert_eq!(
resolved.props,
json!({"app_name": "Acme", "users": "base-wins"})
);
}
#[tokio::test]
async fn merges_recorded() {
let req = req_full();
let mut merges = HashSet::new();
merges.insert("notifications".to_string());
let resolved = resolve(ResolveInput {
req: &req,
component: "X",
base: empty_base(json!({"notifications": ["a"]})),
lazies: HashMap::new(),
deferreds: HashMap::new(),
merges,
shared: None,
})
.await;
assert_eq!(resolved.merge_props, vec!["notifications"]);
}
#[tokio::test]
async fn struct_wrappers_detected_via_serialize_tag_aware() {
use crate::props::{Always, Merge};
use serde::Serialize;
#[derive(Serialize)]
struct Page {
users: Vec<&'static str>,
cached: Always<i64>,
notifs: Merge<Vec<&'static str>>,
}
let p = Page {
users: vec!["a", "b"],
cached: Always(42),
notifs: Merge(vec!["x"]),
};
let base = serialize_tag_aware(&p).unwrap();
assert!(base.always_paths.contains("/cached"));
assert!(base.merge_paths.contains("/notifs"));
let req = req_partial("Users/Index", &["users"], &[]);
let resolved = resolve(ResolveInput {
req: &req,
component: "Users/Index",
base,
lazies: HashMap::new(),
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"users": ["a", "b"], "cached": 42}));
assert!(resolved.merge_props.contains(&"notifs".to_string()));
}
#[tokio::test]
async fn json_macro_wrappers_are_detected_and_stripped() {
use crate::props::{Always, Merge};
let value = json!({
"users": ["a", "b"],
"cached": Always(42),
"notifs": Merge(["x"]),
});
let base = serialize_tag_aware(&value).unwrap();
assert!(base.always_paths.contains("/cached"));
assert!(base.merge_paths.contains("/notifs"));
assert_eq!(
base.value,
json!({"users": ["a", "b"], "cached": 42, "notifs": ["x"]})
);
}
#[tokio::test]
async fn stacked_wrappers_record_both_paths_at_the_same_key() {
use crate::props::{Always, Merge};
#[derive(serde::Serialize)]
struct Page {
feed: Always<Merge<Vec<&'static str>>>,
}
let p = Page {
feed: Always(Merge(vec!["a", "b"])),
};
let base = serialize_tag_aware(&p).unwrap();
assert!(base.always_paths.contains("/feed"));
assert!(base.merge_paths.contains("/feed"));
assert_eq!(base.value, json!({"feed": ["a", "b"]}));
}
#[tokio::test]
async fn nested_wrappers_are_stripped_but_do_not_affect_wire_format() {
use crate::props::Merge;
#[derive(serde::Serialize)]
struct Page {
user: User,
}
#[derive(serde::Serialize)]
struct User {
posts: Merge<Vec<&'static str>>,
}
let p = Page {
user: User {
posts: Merge(vec!["a"]),
},
};
let base = serialize_tag_aware(&p).unwrap();
assert!(base.merge_paths.contains("/user/posts"));
assert_eq!(base.value, json!({"user": {"posts": ["a"]}}));
let req = req_full();
let resolved = resolve(ResolveInput {
req: &req,
component: "X",
base,
lazies: HashMap::new(),
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: None,
})
.await;
assert!(resolved.merge_props.is_empty());
}
#[tokio::test]
async fn always_survives_partial_except() {
let req = req_partial("Page", &[], &["cached", "users"]);
let base = SerializedBase {
value: json!({"cached": 42, "users": [1,2]}),
always_paths: ["/cached".to_string()].into_iter().collect(),
merge_paths: HashSet::new(),
};
let resolved = resolve(ResolveInput {
req: &req,
component: "Page",
base,
lazies: HashMap::new(),
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"cached": 42}));
}
#[tokio::test]
async fn only_and_except_collision_on_same_key_drops_it() {
let req = req_partial("Page", &["a", "b"], &["b"]);
let resolved = resolve(ResolveInput {
req: &req,
component: "Page",
base: empty_base(json!({"a": 1, "b": 2, "c": 3})),
lazies: HashMap::new(),
deferreds: HashMap::new(),
merges: HashSet::new(),
shared: None,
})
.await;
assert_eq!(resolved.props, json!({"a": 1}));
}
}