diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index fc3a559d7..44c1837c8 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -717,7 +717,15 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) { pub fn lenient(input: &Value) -> T { let mut base = serde_json::to_value(T::default()).unwrap_or(Value::Null); if base.is_object() { - overlay::(&mut base, &mut Vec::new(), input, 0); + let mut extra = Vec::new(); + overlay::(&mut base, &mut Vec::new(), input, 0, &mut extra); + // Keys with no slot in the defaults (write-only legacy settings, or unknown ones) are + // kept only after the loop, so `base` stays the size of `T` while each key is checked. + for (path, k, v) in extra { + if let Some(parent) = object_at(&mut base, &path) { + parent.insert(k, v); + } + } } serde_json::from_value(base).unwrap_or_default() } @@ -731,18 +739,34 @@ fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map(root: &mut Value, path: &mut Vec, input: &Value, depth: usize) { +fn overlay( + root: &mut Value, + path: &mut Vec, + input: &Value, + depth: usize, + extra: &mut Vec<(Vec, String, Value)>, +) { let Some(map) = input.as_object() else { return }; for (k, v) in map { let Some(parent) = object_at(root, path) else { return }; let old = parent.get(k).cloned(); if depth < 4 && v.is_object() && old.as_ref().is_some_and(Value::is_object) { path.push(k.clone()); - overlay::(root, path, v, depth + 1); + overlay::(root, path, v, depth + 1, extra); path.pop(); continue; } parent.insert(k.clone(), v.clone()); + if old.is_none() { + let ok = serde_json::from_value::(root.clone()).is_ok(); + if let Some(parent) = object_at(root, path) { + parent.remove(k); + } + if ok { + extra.push((path.clone(), k.clone(), v.clone())); + } + continue; + } if serde_json::from_value::(root.clone()).is_err() && let Some(parent) = object_at(root, path) { @@ -806,6 +830,18 @@ mod tests { use crate::theme::Appearance; use crate::{Services, UiState, WordApp}; + #[test] + fn unknown_settings_keys_load_in_linear_time() { + let mut map = serde_json::Map::new(); + for i in 0..20_000 { + map.insert(format!("unknownKey{i}"), json!(i)); + } + map.insert("miniToolbar".into(), json!(false)); + let bytes = serde_json::to_vec(&serde_json::Value::Object(map)).unwrap(); + let state = UiState::from_json(&bytes).unwrap(); + assert!(!state.mini_toolbar); + } + fn app() -> WordApp { WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()) }