we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement navigation history stack (Phase 17)

Add session history tracking with back/forward traversal for the browser
engine. NavigationHistory maintains a bounded stack of HistoryEntry values
(URL, scroll position, state, title) with a current-index cursor.

- Push new entries on link click and form submission navigation
- Save/restore scroll position on traversal
- Discard forward entries when navigating from mid-stack
- Enforce MAX_HISTORY_SIZE (50) to bound memory usage
- Cmd+[ / Cmd+] keyboard shortcuts for back/forward
- 9 unit tests covering all stack operations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+375
+1
crates/browser/src/lib.rs
··· 10 10 pub mod indexeddb; 11 11 pub mod loader; 12 12 pub mod memory_stats; 13 + pub mod navigation_history; 13 14 pub mod script_loader; 14 15 pub mod storage;
+66
crates/browser/src/main.rs
··· 10 10 }; 11 11 use we_browser::img_loader::{collect_images, ImageStore}; 12 12 use we_browser::loader::{Resource, ResourceLoader, ABOUT_BLANK_HTML}; 13 + use we_browser::navigation_history::NavigationHistory; 13 14 use we_browser::script_loader::execute_page_scripts; 14 15 use we_css::parser::Stylesheet; 15 16 use we_dom::{Document, NodeData, NodeId}; ··· 57 58 content_height: f32, 58 59 /// Per-element scroll offsets for overflow:scroll/auto containers. 59 60 scroll_offsets: HashMap<NodeId, (f32, f32)>, 61 + /// Session navigation history (back/forward stack). 62 + history: NavigationHistory, 60 63 } 61 64 62 65 /// The view kind: either Metal-backed or software bitmap. ··· 328 331 const KEY_CODE_C: u16 = 8; 329 332 const KEY_CODE_V: u16 = 9; 330 333 const KEY_CODE_X: u16 = 7; 334 + const KEY_CODE_LBRACKET: u16 = 33; // [ key 335 + const KEY_CODE_RBRACKET: u16 = 30; // ] key 331 336 332 337 /// Returns true if the given element is an editable text control. 333 338 fn is_text_editable(doc: &we_dom::Document, node: NodeId) -> bool { ··· 571 576 }; 572 577 573 578 // 7. Navigate: replace current page with the response. 579 + // Save scroll position before navigating away. 580 + state.history.save_scroll(0.0, state.page_scroll_y); 581 + 574 582 let new_page = load_page(loaded); 583 + 584 + // Push a history entry for the form submission target URL. 585 + state.history.push(new_page.base_url.clone()); 575 586 576 587 // Swap the font: try web fonts from the new page first, then keep existing. 577 588 let new_font = new_page ··· 859 870 fn handle_key_down(key_code: u16, chars: &str, mods: appkit::KeyModifiers) { 860 871 if key_code == KEY_CODE_TAB { 861 872 handle_tab(mods.shift); 873 + return; 874 + } 875 + 876 + // Cmd+[ = navigate back, Cmd+] = navigate forward. 877 + if mods.command && (key_code == KEY_CODE_LBRACKET || key_code == KEY_CODE_RBRACKET) { 878 + STATE.with(|state| { 879 + let mut state = state.borrow_mut(); 880 + if let Some(state) = state.as_mut() { 881 + let delta = if key_code == KEY_CODE_LBRACKET { -1 } else { 1 }; 882 + navigate_history(state, delta); 883 + } 884 + }); 862 885 return; 863 886 } 864 887 ··· 1783 1806 1784 1807 eprintln!("[we] Navigating to: {}", target_url.serialize()); 1785 1808 1809 + // Save the current scroll position before navigating away. 1810 + state.history.save_scroll(0.0, state.page_scroll_y); 1811 + 1786 1812 let loaded = load_from_url(&target_url); 1787 1813 let new_page = load_page(loaded); 1814 + 1815 + // Push a new history entry for the target URL. 1816 + state.history.push(target_url); 1788 1817 1789 1818 // Swap font: prefer web fonts from the new page, then keep existing. 1790 1819 let new_font = new_page ··· 1801 1830 rerender(state); 1802 1831 } 1803 1832 1833 + /// Traverse the session history by `delta` steps (negative = back, positive = 1834 + /// forward). Loads the target page and restores the saved scroll position. 1835 + fn navigate_history(state: &mut BrowserState, delta: i32) { 1836 + // Save current scroll before traversing. 1837 + state.history.save_scroll(0.0, state.page_scroll_y); 1838 + 1839 + let entry = match state.history.traverse_to(delta) { 1840 + Some(e) => e.clone(), 1841 + None => return, 1842 + }; 1843 + 1844 + eprintln!( 1845 + "[we] History traverse ({delta:+}): {}", 1846 + entry.url.serialize() 1847 + ); 1848 + 1849 + let loaded = load_from_url(&entry.url); 1850 + let new_page = load_page(loaded); 1851 + 1852 + let new_font = new_page 1853 + .font_registry 1854 + .find_best_font() 1855 + .or_else(|| font::load_system_font().ok()); 1856 + if let Some(f) = new_font { 1857 + state.font = f; 1858 + } 1859 + 1860 + state.page = new_page; 1861 + state.page_scroll_y = entry.scroll_y; 1862 + state.scroll_offsets.clear(); 1863 + rerender(state); 1864 + } 1865 + 1804 1866 /// Called by the platform crate on scroll wheel events. 1805 1867 fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { 1806 1868 STATE.with(|state| { ··· 2104 2166 &scroll_offsets, 2105 2167 ); 2106 2168 2169 + // Initialise session history with the first page's URL. 2170 + let history = NavigationHistory::new(page.base_url.clone()); 2171 + 2107 2172 // Store state for the resize handler. 2108 2173 STATE.with(|state| { 2109 2174 *state.borrow_mut() = Some(BrowserState { ··· 2115 2180 page_scroll_y: 0.0, 2116 2181 content_height, 2117 2182 scroll_offsets, 2183 + history, 2118 2184 }); 2119 2185 }); 2120 2186
+308
crates/browser/src/navigation_history.rs
··· 1 + //! Session history stack for navigation. 2 + //! 3 + //! Tracks visited pages and allows back/forward traversal, powering both the 4 + //! History API and browser chrome back/forward buttons. 5 + 6 + use we_url::Url; 7 + 8 + /// Maximum number of entries in the session history to prevent unbounded 9 + /// memory growth. 10 + const MAX_HISTORY_SIZE: usize = 50; 11 + 12 + /// A single entry in the session history. 13 + #[derive(Debug, Clone)] 14 + pub struct HistoryEntry { 15 + /// The URL of the page. 16 + pub url: Url, 17 + /// Saved vertical scroll position. 18 + pub scroll_x: f32, 19 + /// Saved horizontal scroll position. 20 + pub scroll_y: f32, 21 + /// Serialized state data from `pushState`/`replaceState` (opaque string). 22 + pub state: Option<String>, 23 + /// Document title at the time of navigation. 24 + pub title: String, 25 + } 26 + 27 + /// The session history stack. 28 + /// 29 + /// Maintains an ordered list of [`HistoryEntry`] values with a current-index 30 + /// cursor. Navigating to a new page pushes an entry after the current 31 + /// position, discarding any forward entries. 32 + #[derive(Debug)] 33 + pub struct NavigationHistory { 34 + entries: Vec<HistoryEntry>, 35 + /// Index of the current entry. Always valid when `entries` is non-empty. 36 + current: usize, 37 + } 38 + 39 + impl NavigationHistory { 40 + /// Create a new history with an initial entry for the given URL. 41 + pub fn new(url: Url) -> Self { 42 + let entry = HistoryEntry { 43 + url, 44 + scroll_x: 0.0, 45 + scroll_y: 0.0, 46 + state: None, 47 + title: String::new(), 48 + }; 49 + Self { 50 + entries: vec![entry], 51 + current: 0, 52 + } 53 + } 54 + 55 + /// Push a new navigation entry. Discards any forward entries beyond the 56 + /// current position and enforces [`MAX_HISTORY_SIZE`]. 57 + pub fn push(&mut self, url: Url) { 58 + // Discard forward entries. 59 + self.entries.truncate(self.current + 1); 60 + 61 + let entry = HistoryEntry { 62 + url, 63 + scroll_x: 0.0, 64 + scroll_y: 0.0, 65 + state: None, 66 + title: String::new(), 67 + }; 68 + self.entries.push(entry); 69 + self.current = self.entries.len() - 1; 70 + 71 + // Enforce size limit by dropping oldest entries. 72 + if self.entries.len() > MAX_HISTORY_SIZE { 73 + let excess = self.entries.len() - MAX_HISTORY_SIZE; 74 + self.entries.drain(..excess); 75 + self.current -= excess; 76 + } 77 + } 78 + 79 + /// Replace the current entry's URL (used by `location.replace()`). 80 + pub fn replace_current(&mut self, url: Url) { 81 + if let Some(entry) = self.entries.get_mut(self.current) { 82 + entry.url = url; 83 + entry.scroll_x = 0.0; 84 + entry.scroll_y = 0.0; 85 + entry.state = None; 86 + entry.title = String::new(); 87 + } 88 + } 89 + 90 + /// Save the current scroll position into the current entry. 91 + pub fn save_scroll(&mut self, scroll_x: f32, scroll_y: f32) { 92 + if let Some(entry) = self.entries.get_mut(self.current) { 93 + entry.scroll_x = scroll_x; 94 + entry.scroll_y = scroll_y; 95 + } 96 + } 97 + 98 + /// Attempt to traverse backward by one step. Returns `Some(&HistoryEntry)` 99 + /// for the new current entry, or `None` if already at the start. 100 + pub fn traverse_back(&mut self) -> Option<&HistoryEntry> { 101 + if self.current == 0 { 102 + return None; 103 + } 104 + self.current -= 1; 105 + Some(&self.entries[self.current]) 106 + } 107 + 108 + /// Attempt to traverse forward by one step. Returns `Some(&HistoryEntry)` 109 + /// for the new current entry, or `None` if already at the end. 110 + pub fn traverse_forward(&mut self) -> Option<&HistoryEntry> { 111 + if self.current + 1 >= self.entries.len() { 112 + return None; 113 + } 114 + self.current += 1; 115 + Some(&self.entries[self.current]) 116 + } 117 + 118 + /// Traverse by `delta` steps (negative = back, positive = forward). 119 + /// Returns `Some(&HistoryEntry)` for the new current entry, or `None` if 120 + /// the delta is out of range or zero. 121 + pub fn traverse_to(&mut self, delta: i32) -> Option<&HistoryEntry> { 122 + if delta == 0 { 123 + return None; 124 + } 125 + let target = self.current as i64 + delta as i64; 126 + if target < 0 || target >= self.entries.len() as i64 { 127 + return None; 128 + } 129 + self.current = target as usize; 130 + Some(&self.entries[self.current]) 131 + } 132 + 133 + /// Returns the current history entry. 134 + pub fn current_entry(&self) -> &HistoryEntry { 135 + &self.entries[self.current] 136 + } 137 + 138 + /// Returns the number of entries in the history. 139 + pub fn len(&self) -> usize { 140 + self.entries.len() 141 + } 142 + 143 + /// Returns `true` if the history is empty (should never happen after construction). 144 + pub fn is_empty(&self) -> bool { 145 + self.entries.is_empty() 146 + } 147 + 148 + /// Returns `true` if back traversal is possible. 149 + pub fn can_go_back(&self) -> bool { 150 + self.current > 0 151 + } 152 + 153 + /// Returns `true` if forward traversal is possible. 154 + pub fn can_go_forward(&self) -> bool { 155 + self.current + 1 < self.entries.len() 156 + } 157 + 158 + /// Returns the current index in the history. 159 + pub fn current_index(&self) -> usize { 160 + self.current 161 + } 162 + } 163 + 164 + #[cfg(test)] 165 + mod tests { 166 + use super::*; 167 + 168 + fn url(s: &str) -> Url { 169 + Url::parse(s).unwrap() 170 + } 171 + 172 + #[test] 173 + fn new_creates_single_entry() { 174 + let h = NavigationHistory::new(url("https://example.com/")); 175 + assert_eq!(h.len(), 1); 176 + assert_eq!(h.current_index(), 0); 177 + assert_eq!(h.current_entry().url.serialize(), "https://example.com/"); 178 + } 179 + 180 + #[test] 181 + fn push_adds_entry() { 182 + let mut h = NavigationHistory::new(url("https://a.com/")); 183 + h.push(url("https://b.com/")); 184 + assert_eq!(h.len(), 2); 185 + assert_eq!(h.current_index(), 1); 186 + assert_eq!(h.current_entry().url.serialize(), "https://b.com/"); 187 + } 188 + 189 + #[test] 190 + fn push_discards_forward_entries() { 191 + let mut h = NavigationHistory::new(url("https://a.com/")); 192 + h.push(url("https://b.com/")); 193 + h.push(url("https://c.com/")); 194 + // Go back to b.com. 195 + h.traverse_back(); 196 + assert_eq!(h.current_entry().url.serialize(), "https://b.com/"); 197 + // Push d.com — c.com should be discarded. 198 + h.push(url("https://d.com/")); 199 + assert_eq!(h.len(), 3); // a, b, d 200 + assert_eq!(h.current_index(), 2); 201 + assert!(!h.can_go_forward()); 202 + } 203 + 204 + #[test] 205 + fn traverse_back_and_forward() { 206 + let mut h = NavigationHistory::new(url("https://a.com/")); 207 + h.push(url("https://b.com/")); 208 + h.push(url("https://c.com/")); 209 + 210 + // Back to b. 211 + let e = h.traverse_back().unwrap(); 212 + assert_eq!(e.url.serialize(), "https://b.com/"); 213 + 214 + // Back to a. 215 + let e = h.traverse_back().unwrap(); 216 + assert_eq!(e.url.serialize(), "https://a.com/"); 217 + 218 + // Can't go further back. 219 + assert!(h.traverse_back().is_none()); 220 + assert_eq!(h.current_index(), 0); 221 + 222 + // Forward to b. 223 + let e = h.traverse_forward().unwrap(); 224 + assert_eq!(e.url.serialize(), "https://b.com/"); 225 + 226 + // Forward to c. 227 + let e = h.traverse_forward().unwrap(); 228 + assert_eq!(e.url.serialize(), "https://c.com/"); 229 + 230 + // Can't go further forward. 231 + assert!(h.traverse_forward().is_none()); 232 + } 233 + 234 + #[test] 235 + fn traverse_to_delta() { 236 + let mut h = NavigationHistory::new(url("https://a.com/")); 237 + h.push(url("https://b.com/")); 238 + h.push(url("https://c.com/")); 239 + h.push(url("https://d.com/")); 240 + 241 + // Go back 2 steps (to b.com). 242 + let e = h.traverse_to(-2).unwrap(); 243 + assert_eq!(e.url.serialize(), "https://b.com/"); 244 + 245 + // Go forward 1 step (to c.com). 246 + let e = h.traverse_to(1).unwrap(); 247 + assert_eq!(e.url.serialize(), "https://c.com/"); 248 + 249 + // Delta 0 returns None. 250 + assert!(h.traverse_to(0).is_none()); 251 + 252 + // Out-of-range returns None. 253 + assert!(h.traverse_to(-10).is_none()); 254 + assert!(h.traverse_to(10).is_none()); 255 + } 256 + 257 + #[test] 258 + fn replace_current_updates_entry() { 259 + let mut h = NavigationHistory::new(url("https://a.com/")); 260 + h.push(url("https://b.com/")); 261 + h.replace_current(url("https://replaced.com/")); 262 + assert_eq!(h.len(), 2); 263 + assert_eq!(h.current_entry().url.serialize(), "https://replaced.com/"); 264 + // First entry unchanged. 265 + h.traverse_back(); 266 + assert_eq!(h.current_entry().url.serialize(), "https://a.com/"); 267 + } 268 + 269 + #[test] 270 + fn save_scroll_persists_and_restores() { 271 + let mut h = NavigationHistory::new(url("https://a.com/")); 272 + h.save_scroll(10.0, 200.0); 273 + h.push(url("https://b.com/")); 274 + 275 + let e = h.traverse_back().unwrap(); 276 + assert_eq!(e.scroll_x, 10.0); 277 + assert_eq!(e.scroll_y, 200.0); 278 + } 279 + 280 + #[test] 281 + fn history_size_is_bounded() { 282 + let mut h = NavigationHistory::new(url("https://page0.com/")); 283 + for i in 1..=60 { 284 + h.push(url(&format!("https://page{i}.com/"))); 285 + } 286 + assert_eq!(h.len(), MAX_HISTORY_SIZE); 287 + // Current entry should be the latest. 288 + assert_eq!(h.current_entry().url.serialize(), "https://page60.com/"); 289 + // Oldest entry should be page11 (0..10 were evicted). 290 + h.traverse_to(-(h.current_index() as i32)); 291 + assert_eq!(h.current_entry().url.serialize(), "https://page11.com/"); 292 + } 293 + 294 + #[test] 295 + fn can_go_back_and_forward() { 296 + let mut h = NavigationHistory::new(url("https://a.com/")); 297 + assert!(!h.can_go_back()); 298 + assert!(!h.can_go_forward()); 299 + 300 + h.push(url("https://b.com/")); 301 + assert!(h.can_go_back()); 302 + assert!(!h.can_go_forward()); 303 + 304 + h.traverse_back(); 305 + assert!(!h.can_go_back()); 306 + assert!(h.can_go_forward()); 307 + } 308 + }