From 9b97e06f7128fd3aa01db2e625f67eff0cd759db Mon Sep 17 00:00:00 2001 From: austin62jake Date: Thu, 29 Oct 2026 21:36:10 +0000 Subject: [PATCH] Web: open tabs share settings changes, so one tab no longer undoes another's (#8644) (#8832) * claim: #8644 Co-Authored-By: Claude Opus 5.5 * Web: open tabs share settings changes, so one tab no longer undoes another's (#8644) Every open tab of the web app saved its whole copy of the settings to the same browser storage, every 30 s and on focus loss, so a tab still holding old settings put them back over a toolbar change made in another tab. A tab now writes its settings in the frame they change, and the other open tabs load them from the browser's storage event, so their own saves keep them. Settings loaded that way aren't written back. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude --- apps/solidcraft-web/Cargo.toml | 2 +- apps/solidcraft-web/src/main.rs | 136 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/prefs.rs | 24 ++++++ 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index 2339990a38..03eccbe8ff 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -34,7 +34,7 @@ serde_json = { workspace = true } web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", - "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", + "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent", "IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance", diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 403898f5fc..08443ca6a2 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -119,6 +119,43 @@ fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown: design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w") } +/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644). +/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every +/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a +/// change made in another. A change is now written in the frame it is made, and the other tabs +/// load it ([`settings_written`]). +#[cfg(any(target_arch = "wasm32", test))] +#[derive(Default, Debug)] +struct PrefsWriter { + written: Option, +} + +#[cfg(any(target_arch = "wasm32", test))] +impl PrefsWriter { + /// `prefs` if they are not the settings written or loaded last (they count as written from + /// now on). + fn take(&mut self, prefs: String) -> Option { + if self.written.as_deref() == Some(prefs.as_str()) { + return None; + } + self.written = Some(prefs.clone()); + Some(prefs) + } + + /// The settings the tab holds after loading another tab's: the tab doesn't write them back. + fn loaded(&mut self, prefs: String) { + self.written = Some(prefs); + } +} + +/// The settings another tab wrote, from a `storage` event (the browser sends one to every other +/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's +/// data), leave this tab's settings as they are. +#[cfg(any(target_arch = "wasm32", test))] +fn settings_written(key: Option<&str>, value: Option) -> Option { + value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY)) +} + #[cfg(target_arch = "wasm32")] mod web { use std::rc::Rc; @@ -217,6 +254,24 @@ mod web { on_key.forget(); } + /// Settings another tab wrote, waiting for this tab's next frame (#8644). + type PrefsInbox = Rc>>; + + /// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which + /// loads them. + fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) { + let ctx = ctx.clone(); + let on_storage = wasm_bindgen::closure::Closure::::new(move |e: web_sys::StorageEvent| { + if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) { + inbox.set(Some(prefs)); + ctx.request_repaint(); + } + }); + let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref()); + // The listener lives as long as the page. + on_storage.forget(); + } + const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf"; type FontLoadResult = Rc, String>>>>; @@ -246,9 +301,48 @@ mod web { font_language: String, space_mouse: SpaceMouse, wheel_gestures: solidcraft_ui_egui::WebWheelGestures, + /// The settings this tab wrote or loaded last, and those another tab wrote since (#8644). + prefs_writer: crate::PrefsWriter, + other_tab_prefs: PrefsInbox, } impl Shell { + /// The settings as they are kept between visits: with the user's own language, not a + /// session-only English fallback. + fn persisted_prefs(&mut self) -> String { + let saved = self.font.persisted_language(&self.app.preferences.language).to_owned(); + let live = std::mem::replace(&mut self.app.preferences.language, saved); + let prefs = self.app.prefs(); + self.app.preferences.language = live; + prefs + } + + /// A settings change goes to the browser's storage in the frame it is made, and from + /// there to the other open tabs (#8644). Input is what changes settings, so a frame + /// without any is skipped; eframe's own save still catches anything else. + fn store_prefs(&mut self, ctx: &egui::Context) { + if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) { + return; + } + let prefs = self.persisted_prefs(); + let Some(prefs) = self.prefs_writer.take(prefs) else { return }; + let stored = + web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs)); + if !matches!(stored, Some(Ok(()))) { + log::warn!("settings: the browser's storage did not take them; they are saved again later"); + self.prefs_writer = crate::PrefsWriter::default(); + } + } + + /// Settings another tab wrote replace this tab's, so this tab's own saves keep them + /// instead of putting its older copy back (#8644). + fn load_other_tabs_prefs(&mut self) { + let Some(prefs) = self.other_tab_prefs.take() else { return }; + self.app.load_prefs(&prefs); + let held = self.persisted_prefs(); + self.prefs_writer.loaded(held); + } + /// Files from the computer, writes to store, messages and the autosave. fn files_tick(&mut self, ctx: &egui::Context) { if !self.launch.persists() { @@ -647,6 +741,8 @@ mod web { impl eframe::App for Shell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // First: a language another tab chose fetches its font this frame. + self.load_other_tabs_prefs(); self.tick_font(ctx); self.app.logic(ctx); self.api.poll(); @@ -689,6 +785,7 @@ mod web { self.recovery_prompt(&ctx); } self.watch_text_for_font(&ctx); + self.store_prefs(&ctx); } fn persist_egui_memory(&self) -> bool { self.launch.persists() @@ -697,11 +794,8 @@ mod web { if !self.launch.persists() { return; } - // Persist the user's own language, not a session-only English fallback. - let saved = self.font.persisted_language(&self.app.preferences.language).to_owned(); - let live = std::mem::replace(&mut self.app.preferences.language, saved); - storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs()); - self.app.preferences.language = live; + let prefs = self.persisted_prefs(); + storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs); } } @@ -822,6 +916,12 @@ mod web { let (sender, receiver) = std::sync::mpsc::channel(); let mut app = SolveApp::new(Session::default(), services).with_control(receiver); app.embedded = launch.embed; + // Listening before reading: settings another tab writes from now on are + // newer than those read here. + let other_tab_prefs = PrefsInbox::default(); + if launch.persists() { + hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone()); + } if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) { app.load_prefs(&p); } @@ -865,6 +965,8 @@ mod web { font_language, space_mouse: SpaceMouse::default(), wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(), + prefs_writer: crate::PrefsWriter::default(), + other_tab_prefs, })) }), ) @@ -1001,4 +1103,28 @@ mod tests { assert!(!closes_design("w", false, false, false, true)); assert!(!closes_design("s", true, false, false, true)); } + + /// A tab writes its settings when they change, and not again while they stay the same; the + /// settings it loaded from another tab aren't written back (#8644). + #[test] + fn changed_settings_are_written_once_and_loaded_ones_not_at_all() { + let mut writer = PrefsWriter::default(); + assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written"); + assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again"); + assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once"); + writer.loaded(r#"{"a":3}"#.into()); + assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back"); + assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written"); + } + + /// Only the settings another tab wrote are loaded: not other keys, and not settings taken + /// away by clearing the site's data (#8644). + #[test] + fn only_settings_another_tab_wrote_are_loaded() { + let key = solidcraft_ui_egui::prefs::STORAGE_KEY; + assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}")); + assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None); + assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's"); + assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings"); + } } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 25665accfb..a0f5d73dc1 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -5069,6 +5069,30 @@ mod tests { assert_eq!(app.preferences.ui_font, ""); } + /// The web app's open tabs share their settings (#8644): a running tab loads what another + /// tab wrote over settings of its own. Everything it had changed is replaced, a pin or a + /// shortcut the other tab doesn't have included, so both tabs then save the same settings. + #[test] + fn settings_loaded_over_changed_ones_replace_them_all() { + let new_app = || crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let mut writer = new_app(); + writer.sbox.toggle_pin("sketch.line"); + writer.ui.dark = !writer.ui.dark; + writer.keymap.custom.insert("view.fit".into(), vec!["Shift+F".into()]); + let mut reader = new_app(); + reader.sbox.toggle_pin("feature.extrude"); + reader.keymap.custom.insert("view.home".into(), vec!["H".into()]); + reader.toolbar_custom.panels.insert(format!("{}/SOLID/CREATE", crate::toolbar_custom::WORKSPACE), vec!["feature.extrude".into()]); + reader.tree.collapsed.insert("Bodies".into()); + reader.home.recent.push("part.solidcraft".into()); + reader.preferences.ui_scale = 1.5; + reader.load_prefs(&writer.prefs()); + assert_eq!(reader.sbox.pinned, ["sketch.line"], "the other tab's pins, not this tab's"); + assert!(!reader.keymap.custom.contains_key("view.home")); + assert_eq!(reader.ui.dark, writer.ui.dark); + assert_eq!(reader.prefs(), writer.prefs(), "both tabs save the same settings"); + } + /// Bodies folders (#4217): Several bodies only by default, also in preferences saved before it /// existed. Each value is saved and read back; `ui.prefs` refuses anything else and changes /// nothing, and saved preferences read such a value as the default and keep the rest. The