use maud::{Markup, PreEscaped};
use super::{Cache, get_cached, insert_cached};
pub fn cache_fragment(
cache: Option<&dyn Cache>,
identity: impl std::fmt::Display,
version: impl std::fmt::Display,
ttl: Option<std::time::Duration>,
render: impl FnOnce() -> Markup,
) -> Markup {
let Some(cache) = cache else {
return render();
};
let identity = identity.to_string();
let key = format!("fragment:{}:{identity}:{version}", identity.len());
if let Some(html) = get_cached::<String>(cache, &key) {
return PreEscaped(html);
}
let markup = render();
insert_cached(cache, &key, markup.0.clone(), ttl);
markup
}
pub fn cache_fragment_global(
identity: impl std::fmt::Display,
version: impl std::fmt::Display,
ttl: Option<std::time::Duration>,
render: impl FnOnce() -> Markup,
) -> Markup {
let global = super::global_cache();
cache_fragment(global.as_deref(), identity, version, ttl, render)
}
#[cfg(all(test, feature = "cache-moka"))]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use maud::{Markup, html};
use super::{cache_fragment, cache_fragment_global};
use crate::cache::{Cache, MokaCache, clear_global_cache, set_global_cache};
fn make_cache(capacity: u64) -> MokaCache {
MokaCache::new(capacity, None)
}
#[test]
fn hit_does_not_run_closure() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let first = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:1", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "rendered" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"miss must run closure once"
);
assert!(first.into_string().contains("rendered"));
let second = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:1", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "SHOULD NOT APPEAR" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"hit must not run the render closure"
);
assert!(second.into_string().contains("rendered"));
}
#[test]
fn version_bump_causes_miss() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let v1 = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:7", "2024-01-01", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "version-one" } }
})
};
assert_eq!(counter.load(Ordering::SeqCst), 1);
assert!(v1.into_string().contains("version-one"));
let v2 = {
let counter = counter.clone();
cache_fragment(Some(&cache), "post:7", "2024-06-01", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "version-two" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"version bump must produce a miss"
);
assert!(v2.into_string().contains("version-two"));
}
#[test]
fn no_cache_renders_directly_without_panic() {
let counter = Arc::new(AtomicUsize::new(0));
for _ in 0..3 {
let counter = counter.clone();
let result = cache_fragment(None, "post:99", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { span { "fallback" } }
});
assert!(result.into_string().contains("fallback"));
}
assert_eq!(
counter.load(Ordering::SeqCst),
3,
"no cache must render on every call"
);
}
#[test]
fn ttl_parameter_accepted_and_hit_still_works() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
{
let counter = counter.clone();
cache_fragment(
Some(&cache),
"post:2",
"v1",
Some(Duration::from_secs(60)),
move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { em { "ttl-test" } }
},
);
}
assert_eq!(counter.load(Ordering::SeqCst), 1);
{
let counter = counter.clone();
cache_fragment(
Some(&cache),
"post:2",
"v1",
Some(Duration::from_secs(60)),
move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { em { "ttl-test" } }
},
);
}
assert_eq!(counter.load(Ordering::SeqCst), 1, "hit must not re-render");
}
fn inner(cache: &dyn Cache, id: &str, version: &str, counter: &Arc<AtomicUsize>) -> Markup {
let counter = counter.clone();
let version_owned = version.to_owned();
cache_fragment(Some(cache), id, version, None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { li { "fragment " (version_owned) } }
})
}
#[test]
fn russian_doll_nesting_sibling_hit_unchanged_inner() {
let cache = make_cache(100);
let cache_ref: &dyn Cache = &cache;
let inner_a = Arc::new(AtomicUsize::new(0));
let inner_b = Arc::new(AtomicUsize::new(0));
let outer = Arc::new(AtomicUsize::new(0));
{
let a = inner(cache_ref, "inner:a", "v1", &inner_a);
let b = inner(cache_ref, "inner:b", "v1", &inner_b);
let outer_c = outer.clone();
cache_fragment(Some(cache_ref), "outer:list", "outer-v1", None, move || {
outer_c.fetch_add(1, Ordering::SeqCst);
html! { ul { (a) (b) } }
});
}
assert_eq!(inner_a.load(Ordering::SeqCst), 1, "inner-a warmed once");
assert_eq!(inner_b.load(Ordering::SeqCst), 1, "inner-b warmed once");
assert_eq!(outer.load(Ordering::SeqCst), 1, "outer warmed once");
{
let a = inner(cache_ref, "inner:a", "v1", &inner_a);
let b = inner(cache_ref, "inner:b", "v1", &inner_b);
let outer_c = outer.clone();
cache_fragment(Some(cache_ref), "outer:list", "outer-v1", None, move || {
outer_c.fetch_add(1, Ordering::SeqCst);
html! { ul { (a) (b) } }
});
}
assert_eq!(inner_a.load(Ordering::SeqCst), 1, "inner-a stays cached");
assert_eq!(inner_b.load(Ordering::SeqCst), 1, "inner-b stays cached");
assert_eq!(
outer.load(Ordering::SeqCst),
1,
"outer hit: not re-rendered"
);
{
let a = inner(cache_ref, "inner:a", "v2", &inner_a); let b = inner(cache_ref, "inner:b", "v1", &inner_b); let outer_c = outer.clone();
cache_fragment(Some(cache_ref), "outer:list", "outer-v2", None, move || {
outer_c.fetch_add(1, Ordering::SeqCst);
html! { ul { (a) (b) } }
});
}
assert_eq!(
inner_a.load(Ordering::SeqCst),
2,
"inner-a re-renders on version bump"
);
assert_eq!(
inner_b.load(Ordering::SeqCst),
1,
"inner-b sibling stays cached"
);
assert_eq!(
outer.load(Ordering::SeqCst),
2,
"outer re-renders when its version bumped"
);
}
#[test]
fn global_variant_hits_process_global_cache() {
clear_global_cache();
let moka = Arc::new(MokaCache::new(100, None));
set_global_cache(moka as Arc<dyn Cache>);
let counter = Arc::new(AtomicUsize::new(0));
{
let counter = counter.clone();
cache_fragment_global("post:global", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { div { "global" } }
});
}
assert_eq!(counter.load(Ordering::SeqCst), 1, "first call must miss");
{
let counter = counter.clone();
cache_fragment_global("post:global", "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { div { "global" } }
});
}
assert_eq!(counter.load(Ordering::SeqCst), 1, "second call must hit");
clear_global_cache();
}
#[test]
fn global_variant_no_global_cache_renders_fallback() {
clear_global_cache();
let result = cache_fragment_global("post:fallback", "v1", None, || html! { span { "ok" } });
assert!(
result.into_string().contains("ok"),
"must render when no global cache"
);
}
#[test]
fn cached_markup_matches_rendered_markup() {
let cache = make_cache(10);
let first: Markup = cache_fragment(Some(&cache), "post:html", "v1", None, || {
html! { article { h1 { "Hello" } p { "World" } } }
});
let first_html = first.into_string();
let second: Markup = cache_fragment(Some(&cache), "post:html", "v1", None, || {
html! { span { "WRONG" } }
});
let second_html = second.into_string();
assert_eq!(first_html, second_html, "cached markup must equal original");
assert!(second_html.contains("Hello"));
assert!(second_html.contains("World"));
assert!(!second_html.contains("WRONG"));
}
#[test]
fn different_identities_are_independent() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let render = |id: &str| {
let counter = counter.clone();
cache_fragment(Some(&cache), id, "v1", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { span { "x" } }
});
};
render("post:A");
render("post:B");
render("post:A"); render("post:B");
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"each identity must be cached independently"
);
}
#[test]
fn colon_in_identity_does_not_alias_distinct_fragments() {
let cache = make_cache(100);
let counter = Arc::new(AtomicUsize::new(0));
let first = {
let counter = counter.clone();
cache_fragment(Some(&cache), "a:b", "c", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "left" } }
})
};
assert_eq!(counter.load(Ordering::SeqCst), 1, "first pair is a miss");
assert!(first.into_string().contains("left"));
let second = {
let counter = counter.clone();
cache_fragment(Some(&cache), "a", "b:c", None, move || {
counter.fetch_add(1, Ordering::SeqCst);
html! { p { "right" } }
})
};
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"a `:` in the identity must not collide with a different (identity, version) split"
);
assert!(second.into_string().contains("right"));
}
}