use std::collections::BTreeMap;
use dioxus::prelude::*;
use crate::experiments::config::TrackedEvent;
pub type ExposureSink = EventHandler<TrackedEvent>;
#[component]
pub fn ExperimentTracker(
experiment: String,
variant: String,
on_event: Option<EventHandler<TrackedEvent>>,
children: Element,
) -> Element {
let next = Tracked {
experiment,
variant,
has_sink: on_event.is_some(),
};
let seed = next.clone();
let mut ctx = use_context_provider(move || ExperimentCtx {
tracked: Signal::new(seed),
sink: CopyValue::new(on_event),
});
ctx.sink.set(on_event);
if *ctx.tracked.peek() != next {
ctx.tracked.set(next);
}
use_effect(move || {
let (experiment, variant) = {
let tracked = ctx.tracked.read();
(tracked.experiment.clone(), tracked.variant.clone())
};
if let Some(handler) = *ctx.sink.peek() {
handler.call(TrackedEvent::exposure(&experiment, &variant));
}
});
rsx! {
{children}
}
}
pub fn use_experiment() -> (String, String) {
let ctx = use_context::<ExperimentCtx>();
let tracked = ctx.tracked.read();
(tracked.experiment.clone(), tracked.variant.clone())
}
pub fn use_experiment_event() -> impl Fn(&str, BTreeMap<String, String>) {
let ctx = use_context::<ExperimentCtx>();
move |action: &str, props: BTreeMap<String, String>| {
let Some(handler) = *ctx.sink.peek() else { return };
let (experiment, variant) = {
let tracked = ctx.tracked.peek();
(tracked.experiment.clone(), tracked.variant.clone())
};
handler.call(TrackedEvent::action(&experiment, &variant, action, props));
}
}
#[derive(Clone, Copy)]
struct ExperimentCtx {
tracked: Signal<Tracked>,
sink: CopyValue<Option<ExposureSink>>,
}
#[derive(Clone, PartialEq)]
struct Tracked {
experiment: String,
variant: String,
has_sink: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::experiments::test_util::render;
#[test]
fn tracker_provides_context_to_descendants() {
#[component]
fn Probe() -> Element {
let _track = use_experiment_event();
let (experiment, variant) = use_experiment();
rsx! {
span { "{experiment}:{variant}" }
}
}
fn app() -> Element {
rsx! {
ExperimentTracker { experiment: "hero".to_string(), variant: "b".to_string(), Probe {} }
}
}
let html = render(app);
assert!(html.contains("hero:b"), "{html}");
}
#[test]
fn tracker_renders_children() {
fn app() -> Element {
rsx! {
ExperimentTracker { experiment: "team".to_string(), variant: "a".to_string(),
p { "body" }
}
}
}
assert!(render(app).contains("body"));
}
#[test]
fn tracker_tracks_prop_updates() {
use std::cell::RefCell;
thread_local! {
static VARIANT: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
}
#[component]
fn Probe() -> Element {
let (experiment, variant) = use_experiment();
rsx! {
span { "{experiment}:{variant}" }
}
}
fn app() -> Element {
let variant = use_signal(|| "a".to_string());
VARIANT.with(|slot| *slot.borrow_mut() = Some(variant));
rsx! {
ExperimentTracker { experiment: "hero".to_string(), variant: variant(), Probe {} }
}
}
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
assert!(dioxus_ssr::render(&dom).contains("hero:a"));
let mut variant = VARIANT.with(|slot| slot.borrow().expect("app captured its variant signal"));
dom.in_runtime(|| variant.set("b".to_string()));
dom.render_immediate(&mut dioxus::core::NoOpMutations);
let html = dioxus_ssr::render(&dom);
assert!(html.contains("hero:b"), "context must track prop updates: {html}");
}
#[test]
fn exposure_refires_on_prop_change() {
use std::cell::RefCell;
thread_local! {
static EVENTS: RefCell<Vec<TrackedEvent>> = const { RefCell::new(Vec::new()) };
static VARIANT: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
static BUMP: RefCell<Option<Signal<u32>>> = const { RefCell::new(None) };
}
fn app() -> Element {
let variant = use_signal(|| "a".to_string());
VARIANT.with(|slot| *slot.borrow_mut() = Some(variant));
let bump = use_signal(|| 0u32);
bump();
BUMP.with(|slot| *slot.borrow_mut() = Some(bump));
rsx! {
ExperimentTracker {
experiment: "hero".to_string(),
variant: variant(),
on_event: move |event: TrackedEvent| EVENTS.with(|events| events.borrow_mut().push(event)),
span { "x" }
}
}
}
fn settle(dom: &mut VirtualDom) {
for _ in 0..8 {
dom.render_immediate(&mut dioxus::core::NoOpMutations);
}
}
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
settle(&mut dom);
EVENTS.with(|events| {
let events = events.borrow();
assert_eq!(events.len(), 1, "one exposure on mount: {events:?}");
assert_eq!(events[0].variant, "a");
assert_eq!(events[0].name, "hero_exposed");
});
let mut variant = VARIANT.with(|slot| slot.borrow().expect("app captured its variant signal"));
dom.in_runtime(|| variant.set("b".to_string()));
settle(&mut dom);
EVENTS.with(|events| {
let events = events.borrow();
assert_eq!(events.len(), 2, "exposure re-fires on variant change: {events:?}");
assert_eq!(events[1].variant, "b");
});
let mut bump = BUMP.with(|slot| slot.borrow().expect("app captured its bump signal"));
dom.in_runtime(|| bump += 1);
settle(&mut dom);
EVENTS.with(|events| {
assert_eq!(events.borrow().len(), 2, "no exposure on a re-render with unchanged props: {:?}", events.borrow());
});
}
#[test]
fn tracker_without_on_event_renders_without_panicking() {
#[component]
fn Probe() -> Element {
let _track = use_experiment_event();
let (experiment, variant) = use_experiment();
rsx! {
span { "{experiment}:{variant}" }
}
}
fn app() -> Element {
rsx! {
ExperimentTracker { experiment: "hero".to_string(), variant: "a".to_string(), on_event: None,
Probe {}
p { "body" }
}
}
}
let html = render(app);
assert!(html.contains("hero:a"), "{html}");
assert!(html.contains("body"), "{html}");
}
}