use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
use crate::{
Computed, DropResource, KeyedListItem, Value, keyed_computed_list, struct_mut::ValueMut,
transaction,
};
#[derive(Clone, PartialEq, Debug)]
struct Person {
id: &'static str,
name: &'static str,
age: i32,
}
fn bob() -> Person {
Person {
id: "1",
name: "Bob",
age: 43,
}
}
fn frank(age: i32) -> Person {
Person {
id: "2",
name: "Frank",
age,
}
}
#[derive(Clone)]
struct SignalList(Vec<Person>);
impl PartialEq for SignalList {
fn eq(&self, _other: &Self) -> bool {
false
}
}
#[derive(Clone, Debug, PartialEq)]
struct CleanDumpItem {
id: &'static str,
name: &'static str,
age: i32,
revision: u32,
}
#[derive(Clone, Debug, PartialEq)]
struct CleanDump {
list_revision: u32,
items: Vec<CleanDumpItem>,
}
struct DumpItem {
id: &'static str,
name: RefCell<&'static str>,
age: RefCell<i32>,
revision: RefCell<u32>,
_unsubscribe: RefCell<Option<DropResource>>,
}
struct Dump {
list_revision: u32,
items: Vec<Rc<DumpItem>>,
}
fn watch_dump(
list: Computed<Vec<KeyedListItem<&'static str, Computed<Person>>>>,
) -> (Rc<RefCell<Dump>>, DropResource) {
let dump = Rc::new(RefCell::new(Dump {
list_revision: 0,
items: Vec::new(),
}));
let unsub = list.subscribe({
let dump = dump.clone();
move |rows| {
let mut dump = dump.borrow_mut();
let mut prev: HashMap<&'static str, Rc<DumpItem>> = HashMap::new();
for item in dump.items.drain(..) {
prev.insert(item.id, item);
}
let mut new_items = Vec::new();
for record in rows {
let id = record.key;
if let Some(prev_item) = prev.remove(&id) {
new_items.push(prev_item);
continue;
}
let new_item = Rc::new(DumpItem {
id,
name: RefCell::new(""),
age: RefCell::new(0),
revision: RefCell::new(0),
_unsubscribe: RefCell::new(None),
});
let item_unsub = record.value.subscribe({
let new_item = new_item.clone();
move |person| {
*new_item.name.borrow_mut() = person.name;
*new_item.age.borrow_mut() = person.age;
*new_item.revision.borrow_mut() += 1;
}
});
*new_item._unsubscribe.borrow_mut() = Some(item_unsub);
new_items.push(new_item);
}
dump.items = new_items;
dump.list_revision += 1;
}
});
(dump, unsub)
}
fn get_dump(dump: &RefCell<Dump>) -> CleanDump {
let dump = dump.borrow();
CleanDump {
list_revision: dump.list_revision,
items: dump
.items
.iter()
.map(|item| CleanDumpItem {
id: item.id,
name: *item.name.borrow(),
age: *item.age.borrow(),
revision: *item.revision.borrow(),
})
.collect(),
}
}
fn people_computed(source: &Value<SignalList>) -> Computed<Vec<Person>> {
let source = source.clone();
Computed::from(move |ctx| source.get(ctx).0)
}
#[test]
fn exposes_computed_values_for_the_initial_list() {
let source = Value::new(SignalList(Vec::new()));
let list = keyed_computed_list(people_computed(&source), |item| item.id);
let (dump, _watch) = watch_dump(list);
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 1,
items: vec![],
}
);
source.set(SignalList(vec![bob()]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 2,
items: vec![CleanDumpItem {
id: "1",
name: "Bob",
age: 43,
revision: 1,
}],
}
);
source.set(SignalList(vec![bob()]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 2,
items: vec![CleanDumpItem {
id: "1",
name: "Bob",
age: 43,
revision: 1,
}],
}
);
source.set(SignalList(vec![bob(), frank(23)]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 3,
items: vec![
CleanDumpItem {
id: "1",
name: "Bob",
age: 43,
revision: 1,
},
CleanDumpItem {
id: "2",
name: "Frank",
age: 23,
revision: 1,
},
],
}
);
source.set(SignalList(vec![bob(), frank(24)]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 3,
items: vec![
CleanDumpItem {
id: "1",
name: "Bob",
age: 43,
revision: 1,
},
CleanDumpItem {
id: "2",
name: "Frank",
age: 24,
revision: 2,
},
],
}
);
source.set(SignalList(vec![frank(24)]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 4,
items: vec![CleanDumpItem {
id: "2",
name: "Frank",
age: 24,
revision: 2,
}],
}
);
source.set(SignalList(vec![frank(30)]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 4,
items: vec![CleanDumpItem {
id: "2",
name: "Frank",
age: 30,
revision: 3,
}],
}
);
source.set(SignalList(vec![frank(30)]));
assert_eq!(
get_dump(&dump),
CleanDump {
list_revision: 4,
items: vec![CleanDumpItem {
id: "2",
name: "Frank",
age: 30,
revision: 3,
}],
}
);
}
#[test]
fn unchanged_rows_do_not_notify() {
fn zoe() -> Person {
Person {
id: "3",
name: "Zoe",
age: 30,
}
}
let source = Value::new(SignalList(vec![bob(), frank(23)]));
let list = keyed_computed_list(people_computed(&source), |item| item.id);
let (bob_row, frank_row) = transaction(|ctx| {
let rows = list.get(ctx);
(rows[0].value.clone(), rows[1].value.clone())
});
let bob_calls = Rc::new(Cell::new(0));
let frank_calls = Rc::new(Cell::new(0));
let _bob_sub = bob_row.subscribe({
let bob_calls = bob_calls.clone();
move |_| bob_calls.set(bob_calls.get() + 1)
});
let _frank_sub = frank_row.subscribe({
let frank_calls = frank_calls.clone();
move |_| frank_calls.set(frank_calls.get() + 1)
});
assert_eq!((bob_calls.get(), frank_calls.get()), (1, 1), "initial read");
source.set(SignalList(vec![bob(), frank(23)]));
assert_eq!(
(bob_calls.get(), frank_calls.get()),
(1, 1),
"equal content"
);
source.set(SignalList(vec![frank(23), bob()]));
assert_eq!((bob_calls.get(), frank_calls.get()), (1, 1), "reorder");
source.set(SignalList(vec![frank(23), bob(), zoe()]));
assert_eq!((bob_calls.get(), frank_calls.get()), (1, 1), "new key");
source.set(SignalList(vec![frank(24), bob(), zoe()]));
assert_eq!(
(bob_calls.get(), frank_calls.get()),
(1, 2),
"only Frank changed"
);
}
#[test]
fn can_be_built_during_a_refresh() {
let trigger = Value::new(1);
let ages = Computed::from({
let trigger = trigger.clone();
move |ctx| {
let age = trigger.get(ctx);
let source = Value::new(vec![Person {
id: "1",
name: "Ann",
age,
}]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
list.get(ctx)
.iter()
.map(|row| row.value.get(ctx).age)
.collect::<Vec<_>>()
}
});
let seen = Rc::new(RefCell::new(Vec::new()));
let _subscription = ages.subscribe({
let seen = seen.clone();
move |ages| seen.borrow_mut().push(ages)
});
assert_eq!(*seen.borrow(), vec![vec![1]]);
trigger.set(2);
assert_eq!(*seen.borrow(), vec![vec![1], vec![2]]);
}
#[test]
fn duplicate_keys_during_a_refresh() {
let trigger = Value::new(1);
let names = Computed::from({
let trigger = trigger.clone();
move |ctx| {
let age = trigger.get(ctx);
let source = Value::new(vec![
Person {
id: "1",
name: "first",
age,
},
Person {
id: "1",
name: "duplicate",
age,
},
]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
list.get(ctx)
.iter()
.map(|row| row.value.get(ctx).name)
.collect::<Vec<_>>()
}
});
let seen = Rc::new(RefCell::new(Vec::new()));
let _subscription = names.subscribe({
let seen = seen.clone();
move |names| seen.borrow_mut().push(names)
});
trigger.set(2);
assert_eq!(*seen.borrow(), vec![vec!["first"]]);
}
#[derive(Debug)]
struct Counted {
id: u32,
value: u32,
clones: Rc<Cell<usize>>,
}
impl Clone for Counted {
fn clone(&self) -> Self {
self.clones.set(self.clones.get() + 1);
Counted {
id: self.id,
value: self.value,
clones: self.clones.clone(),
}
}
}
impl PartialEq for Counted {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.value == other.value
}
}
fn clones_for_one_row_update(rows: u32) -> usize {
let clones = Rc::new(Cell::new(0));
let build = |first_value: u32| {
(0..rows)
.map(|id| Counted {
id,
value: if id == 0 { first_value } else { id },
clones: clones.clone(),
})
.collect::<Vec<_>>()
};
let source = Value::new(build(0));
let list = keyed_computed_list(source.to_computed(), |item| item.id);
let _row_subscriptions = transaction(|ctx| list.get(ctx))
.into_iter()
.map(|item| item.value.subscribe(|_| {}))
.collect::<Vec<_>>();
let _list_subscription = list.subscribe(|_| {});
clones.set(0);
source.set(build(1));
clones.get()
}
#[test]
fn one_row_update_scales_linearly() {
let small = clones_for_one_row_update(20);
let large = clones_for_one_row_update(80);
assert!(
large < small * 6,
"updating one row looks quadratic: 20 rows cost {small} clones, \
80 rows cost {large} (linear would be about 4x, quadratic about 16x)"
);
}
#[test]
fn a_stale_row_does_not_pin_the_rest_of_the_list() {
#[derive(Clone, Debug)]
struct Tracked {
id: u32,
#[expect(dead_code, reason = "counted through Rc::strong_count, not read")]
alive: Rc<()>,
}
impl PartialEq for Tracked {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
let retained_after_clearing = |rows: u32| {
let alive = Rc::new(());
let source = Value::new(
(0..rows)
.map(|id| Tracked {
id,
alive: alive.clone(),
})
.collect::<Vec<_>>(),
);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
let stale = transaction(|ctx| list.get(ctx)[3].value.clone());
source.set(Vec::new());
transaction(|ctx| {
assert_eq!(stale.get(ctx).id, 3);
});
Rc::strong_count(&alive)
};
let small = retained_after_clearing(10);
let large = retained_after_clearing(100);
assert_eq!(
small, large,
"a stale row retained {small} items out of 10 and {large} out of 100, \
so it is holding on to the list rather than to its own value"
);
}
#[derive(Debug, Default)]
struct KeyWork {
hashes: Cell<usize>,
clones: Cell<usize>,
}
#[derive(Debug)]
struct CountedKey {
id: u32,
work: Rc<KeyWork>,
}
impl Clone for CountedKey {
fn clone(&self) -> Self {
self.work.clones.set(self.work.clones.get() + 1);
CountedKey {
id: self.id,
work: self.work.clone(),
}
}
}
impl std::hash::Hash for CountedKey {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.work.hashes.set(self.work.hashes.get() + 1);
self.id.hash(state);
}
}
impl PartialEq for CountedKey {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for CountedKey {}
#[derive(Clone, Debug)]
struct KeyedRow {
key: CountedKey,
value: u32,
}
impl PartialEq for KeyedRow {
fn eq(&self, other: &Self) -> bool {
self.key.id == other.key.id && self.value == other.value
}
}
fn key_work_for_one_row_update(rows: u32) -> (usize, usize) {
let work = Rc::new(KeyWork::default());
let build = |first_value: u32| {
(0..rows)
.map(|id| KeyedRow {
key: CountedKey {
id,
work: work.clone(),
},
value: if id == 0 { first_value } else { id },
})
.collect::<Vec<_>>()
};
let source = Value::new(build(0));
let list = keyed_computed_list(source.to_computed(), |item| item.key.clone());
let _row_subscriptions = transaction(|ctx| list.get(ctx))
.into_iter()
.map(|item| item.value.subscribe(|_| {}))
.collect::<Vec<_>>();
let _list_subscription = list.subscribe(|_| {});
work.hashes.set(0);
work.clones.set(0);
source.set(build(1));
(work.hashes.get(), work.clones.get())
}
#[test]
fn one_row_update_stays_within_its_key_budget() {
const ROWS: u32 = 100;
const HASHES_PER_ROW: usize = 4;
const CLONES_PER_ROW: usize = 7;
let (hashes, clones) = key_work_for_one_row_update(ROWS);
assert!(
hashes <= ROWS as usize * HASHES_PER_ROW,
"one update of {ROWS} rows hashed a key {hashes} times, budget is {}",
ROWS as usize * HASHES_PER_ROW
);
assert!(
clones <= ROWS as usize * CLONES_PER_ROW,
"one update of {ROWS} rows cloned a key {clones} times, budget is {}",
ROWS as usize * CLONES_PER_ROW
);
}
#[test]
fn keeps_the_first_item_when_duplicate_keys_appear() {
let source = Value::new(vec![
Person {
id: "1",
name: "first",
age: 1,
},
Person {
id: "1",
name: "second",
age: 2,
},
Person {
id: "2",
name: "other",
age: 3,
},
]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
transaction(|ctx| {
let rows = list.get(ctx);
let values: Vec<Person> = rows.iter().map(|item| item.value.get(ctx)).collect();
let keys: Vec<&'static str> = rows.iter().map(|item| item.key).collect();
assert_eq!(
values,
vec![
Person {
id: "1",
name: "first",
age: 1,
},
Person {
id: "2",
name: "other",
age: 3,
},
]
);
assert_eq!(keys, vec!["1", "2"]);
});
}
fn map_keyed_list_state<T, S, K>(
list: Computed<Vec<KeyedListItem<K, Computed<T>>>>,
create_state: impl Fn(Computed<T>) -> S + 'static,
) -> Computed<Vec<KeyedListItem<K, S>>>
where
T: Clone + PartialEq + 'static,
S: Clone + PartialEq + 'static,
K: Clone + Eq + std::hash::Hash + 'static,
{
let cache = Rc::new(ValueMut::new(HashMap::<K, KeyedListItem<K, S>>::new()));
Computed::from(move |ctx| {
let mut result = Vec::new();
for item in list.get(ctx) {
let next = cache.change(|cache| {
if let Some(prev) = cache.get(&item.key) {
prev.clone()
} else {
KeyedListItem {
key: item.key.clone(),
value: create_state(item.value.clone()),
}
}
});
result.push(next);
}
cache.set(
result
.iter()
.map(|item| (item.key.clone(), item.clone()))
.collect(),
);
result
})
}
#[test]
fn keyed_list_builds_a_keyed_computed_list() {
let source = Value::new(vec![Person {
id: "1",
name: "Ann",
age: 20,
}]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
transaction(|ctx| {
let rows: Vec<(&'static str, Person)> = list
.get(ctx)
.into_iter()
.map(|item| (item.key, item.value.get(ctx)))
.collect();
assert_eq!(
rows,
vec![(
"1",
Person {
id: "1",
name: "Ann",
age: 20,
}
)]
);
});
}
#[derive(Clone)]
struct RowState {
label: Computed<&'static str>,
}
impl PartialEq for RowState {
fn eq(&self, other: &Self) -> bool {
self.label == other.label
}
}
#[test]
fn keyed_list_map_runs_create_state_once_per_key() {
let source = Value::new(vec![Person {
id: "1",
name: "Ann",
age: 20,
}]);
let create_count = Rc::new(std::cell::Cell::new(0));
let list = keyed_computed_list(source.to_computed(), |item| item.id);
let rows = map_keyed_list_state(list, {
let create_count = create_count.clone();
move |person| {
create_count.set(create_count.get() + 1);
RowState {
label: Computed::from({
let person = person.clone();
move |ctx| person.get(ctx).name
}),
}
}
});
let first_label_id = transaction(|ctx| {
let rows = rows.get(ctx);
assert_eq!(create_count.get(), 1);
assert_eq!(rows[0].value.label.get(ctx), "Ann");
rows[0].value.label.id()
});
source.set(vec![Person {
id: "1",
name: "Ann",
age: 21,
}]);
transaction(|ctx| {
let current = rows.get(ctx);
assert_eq!(create_count.get(), 1);
assert_eq!(current[0].value.label.id(), first_label_id);
assert_eq!(current[0].value.label.get(ctx), "Ann");
});
source.set(vec![
Person {
id: "1",
name: "Ann",
age: 21,
},
Person {
id: "2",
name: "Bob",
age: 30,
},
]);
transaction(|ctx| {
let current = rows.get(ctx);
assert_eq!(current.len(), 2);
assert_eq!(create_count.get(), 2);
assert_eq!(current[0].value.label.id(), first_label_id);
});
}
#[test]
fn returns_last_value_after_key_leaves_the_list() {
let source = Value::new(vec![bob()]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
let stale_item = transaction(|ctx| list.get(ctx)[0].value.clone());
source.set(Vec::new());
transaction(|ctx| {
assert_eq!(list.get(ctx).len(), 0);
assert_eq!(stale_item.get(ctx), bob());
});
}
mod log_capture {
use std::cell::RefCell;
thread_local! {
static RECORDS: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
}
struct Capture;
impl log::Log for Capture {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Error
}
fn log(&self, record: &log::Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
RECORDS.with(|slot| {
if let Some(records) = slot.borrow_mut().as_mut() {
records.push(record.args().to_string());
}
});
}
fn flush(&self) {}
}
pub fn errors_during(body: impl FnOnce()) -> Vec<String> {
let _ = log::set_logger(&Capture);
log::set_max_level(log::LevelFilter::Error);
RECORDS.with(|slot| *slot.borrow_mut() = Some(Vec::new()));
body();
RECORDS
.with(|slot| slot.borrow_mut().take())
.unwrap_or_default()
}
}
#[test]
fn removing_a_row_does_not_report_a_read_after_removal() {
use crate::{self as vertigo, dom, render::render_list};
fn item(text: &str) -> (Value<String>, Computed<String>) {
let value = Value::new(text.to_string());
let rendered = value.map(|text| format!("rendered: {text}"));
(value, rendered)
}
let source = Value::new(vec![item("one"), item("two"), item("three")]);
let items: Computed<Vec<Computed<String>>> = Computed::from({
let source = source.clone();
move |ctx| source.get(ctx).into_iter().map(|(_, c)| c).collect()
});
let left = source.render_value(|rows| {
let out = crate::dom_element! { <div /> };
for (value, _) in rows {
out.add_child(dom! { <div>{value}</div> });
}
out.into()
});
let right = render_list(
&items,
|item| item.id(),
|item| item.render_value(|text| dom! { <div>{text}</div> }),
);
let _root = dom! { <div>{left}{right}</div> };
let errors = log_capture::errors_during(|| {
source.change(|current| {
current.remove(1);
});
});
assert!(
errors.is_empty(),
"removing one row of three logged {}: {errors:#?}",
errors.len()
);
}
#[test]
fn removing_a_row_with_hand_rolled_observers_is_quiet() {
fn item(text: &str) -> (Value<String>, Computed<String>) {
let value = Value::new(text.to_string());
let rendered = value.map(|text| format!("rendered: {text}"));
(value, rendered)
}
let source = Value::new(vec![item("one"), item("two"), item("three")]);
let items: Computed<Vec<Computed<String>>> = Computed::from({
let source = source.clone();
move |ctx| source.get(ctx).into_iter().map(|(_, c)| c).collect()
});
let rows = keyed_computed_list(items, |item| item.id());
let _other_sub = source.to_computed().subscribe(|_| {});
let row_subs: Rc<RefCell<HashMap<crate::GraphId, DropResource>>> =
Rc::new(RefCell::new(HashMap::new()));
let _rows_sub = rows.subscribe({
let row_subs = row_subs.clone();
move |rows| {
let mut subs = row_subs.borrow_mut();
let mut kept = HashMap::new();
for row in rows {
match subs.remove(&row.key) {
Some(existing) => {
kept.insert(row.key, existing);
}
None => {
kept.insert(row.key, row.value.subscribe(|_| {}));
}
}
}
*subs = kept;
}
});
let errors = log_capture::errors_during(|| {
source.change(|current| {
current.remove(1);
});
});
assert!(
errors.is_empty(),
"removing one row of three logged {}: {errors:#?}",
errors.len()
);
}
#[test]
fn a_row_read_after_the_list_moves_on_is_reported() {
let source = Value::new(vec![bob(), frank(30)]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
let stale = transaction(|ctx| list.get(ctx)[0].value.clone());
source.set(vec![frank(30)]);
let removal = log_capture::errors_during(|| {
transaction(|ctx| {
assert_eq!(stale.get(ctx), bob());
});
});
assert!(
removal.is_empty(),
"the first read after removal is the removal itself and must be quiet: {removal:#?}"
);
let later = log_capture::errors_during(|| {
for age in 31..34 {
source.set(vec![frank(age)]);
transaction(|ctx| {
assert_eq!(stale.get(ctx), bob());
});
}
});
assert_eq!(
later.len(),
3,
"a row read across three later updates should report each time: {later:#?}"
);
}
#[test]
fn a_returning_key_is_not_still_marked_departed() {
let source = Value::new(vec![bob()]);
let list = keyed_computed_list(source.to_computed(), |item| item.id);
let row = transaction(|ctx| list.get(ctx)[0].value.clone());
source.set(Vec::new());
transaction(|ctx| {
assert_eq!(row.get(ctx), bob());
});
source.set(vec![bob()]);
transaction(|ctx| {
assert_eq!(row.get(ctx), bob());
});
source.set(Vec::new());
let errors = log_capture::errors_during(|| {
transaction(|ctx| {
assert_eq!(row.get(ctx), bob());
});
});
assert!(
errors.is_empty(),
"a key that left, returned and left again reported on its second departure: {errors:#?}"
);
}