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 same-document fragment navigation (Phase 17)

Add fragment navigation support: clicking <a href="#id"> scrolls to the
matching element without reloading the page. Handles fragment-only links,
cross-document URLs that only differ in fragment, and history traversal
for fragment changes.

Changes:
- Url::equals_ignoring_fragment() and Url::set_fragment() for detecting
and manipulating fragment-only URL changes
- Document::find_anchor_by_name() for legacy <a name="..."> fallback
- create_hashchange_event() for the HashChangeEvent JS object
- navigate_fragment() in the browser main loop: updates URL, pushes
history entry, scrolls to target element, fires hashchange
- navigate_to_link() now detects fragment-only links and same-document
fragment changes instead of ignoring them
- navigate_history() avoids page reload for fragment-only traversals
- find_element_y_in_layout() walks the layout tree to find an element's
absolute Y position for scroll targeting

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

+494 -2
+135 -2
crates/browser/src/main.rs
··· 1758 1758 None 1759 1759 } 1760 1760 1761 + // --------------------------------------------------------------------------- 1762 + // Fragment navigation helpers 1763 + // --------------------------------------------------------------------------- 1764 + 1765 + /// Walk the layout tree to find the absolute Y position of the box associated 1766 + /// with `target`. Returns `None` if the node is not in the layout tree. 1767 + fn find_element_y_in_layout( 1768 + layout_box: &we_layout::LayoutBox, 1769 + target: NodeId, 1770 + parent_y: f32, 1771 + ) -> Option<f32> { 1772 + let absolute_y = parent_y + layout_box.rect.y; 1773 + 1774 + let box_node = match layout_box.box_type { 1775 + we_layout::BoxType::Block(n) | we_layout::BoxType::Inline(n) => Some(n), 1776 + we_layout::BoxType::TextRun { node, .. } => Some(node), 1777 + we_layout::BoxType::Anonymous => None, 1778 + }; 1779 + 1780 + if box_node == Some(target) { 1781 + return Some(absolute_y); 1782 + } 1783 + 1784 + for child in &layout_box.children { 1785 + if let Some(y) = find_element_y_in_layout(child, target, absolute_y) { 1786 + return Some(y); 1787 + } 1788 + } 1789 + 1790 + None 1791 + } 1792 + 1793 + /// Perform a layout pass and find the Y position of `target` in the resulting 1794 + /// layout tree. Returns `None` if the element has no layout box. 1795 + fn compute_element_scroll_y(state: &BrowserState, target: NodeId) -> Option<f32> { 1796 + let viewport_width = state.bitmap.width() as f32; 1797 + let viewport_height = state.bitmap.height() as f32; 1798 + 1799 + let styled = resolve_styles( 1800 + &state.page.doc, 1801 + std::slice::from_ref(&state.page.stylesheet), 1802 + (viewport_width, viewport_height), 1803 + )?; 1804 + 1805 + let mut sizes = image_sizes(&state.page.images); 1806 + let svg_sizes = collect_svg_sizes(&state.page.doc); 1807 + sizes.extend(svg_sizes); 1808 + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut sizes); 1809 + 1810 + let tree = layout( 1811 + &styled, 1812 + &state.page.doc, 1813 + viewport_width, 1814 + viewport_height, 1815 + &state.font, 1816 + &sizes, 1817 + ); 1818 + 1819 + find_element_y_in_layout(&tree.root, target, 0.0) 1820 + } 1821 + 1822 + /// Find a fragment target element in the document. First tries `getElementById`, 1823 + /// then falls back to `<a name="...">` for legacy compatibility. 1824 + fn find_fragment_target(doc: &Document, fragment: &str) -> Option<NodeId> { 1825 + if fragment.is_empty() { 1826 + return None; 1827 + } 1828 + doc.get_element_by_id(fragment) 1829 + .or_else(|| doc.find_anchor_by_name(fragment)) 1830 + } 1831 + 1832 + /// Perform same-document fragment navigation: update the URL, push a history 1833 + /// entry, scroll to the target element, and fire the `hashchange` event. 1834 + fn navigate_fragment(state: &mut BrowserState, new_url: Url, fragment: &str) { 1835 + let old_url_str = state.page.base_url.serialize(); 1836 + let new_url_str = new_url.serialize(); 1837 + 1838 + eprintln!("[we] Fragment navigation: {old_url_str} → {new_url_str}"); 1839 + 1840 + // Save current scroll position before changing it. 1841 + state.history.save_scroll(0.0, state.page_scroll_y); 1842 + 1843 + // Push a new history entry for the fragment URL. 1844 + state.history.push(new_url.clone()); 1845 + 1846 + // Update the base URL to include the new fragment. 1847 + state.page.base_url = new_url; 1848 + 1849 + // Scroll to the target element (or top for "#"). 1850 + if fragment.is_empty() { 1851 + state.page_scroll_y = 0.0; 1852 + } else if let Some(target) = find_fragment_target(&state.page.doc, fragment) { 1853 + if let Some(y) = compute_element_scroll_y(state, target) { 1854 + let viewport_height = state.bitmap.height() as f32; 1855 + let max_scroll = (state.content_height - viewport_height).max(0.0); 1856 + state.page_scroll_y = y.clamp(0.0, max_scroll); 1857 + } 1858 + } 1859 + // If no matching element, don't scroll (per spec). 1860 + 1861 + rerender(state); 1862 + 1863 + // Fire the hashchange event (oldURL, newURL) — logged for now; JS event 1864 + // dispatch will be wired once the event loop integrates with the VM. 1865 + eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); 1866 + } 1867 + 1761 1868 /// Navigate to a link target. Resolves the href against the page base URL, 1762 1869 /// loads the new page, and replaces the current browsing context. 1763 1870 fn navigate_to_link(state: &mut BrowserState, href: &str) { ··· 1768 1875 return; 1769 1876 } 1770 1877 1771 - // Ignore fragment-only links for now (same-page scroll — Phase 17 fragment nav). 1772 - if href.starts_with('#') { 1878 + // Handle fragment-only links (#id) as same-document navigation. 1879 + if let Some(fragment) = href.strip_prefix('#') { 1880 + let mut new_url = state.page.base_url.clone(); 1881 + new_url.set_fragment(if fragment.is_empty() { 1882 + None 1883 + } else { 1884 + Some(fragment.to_string()) 1885 + }); 1886 + navigate_fragment(state, new_url, fragment); 1773 1887 return; 1774 1888 } 1775 1889 ··· 1787 1901 } 1788 1902 }; 1789 1903 1904 + // If the target URL only differs in the fragment, do same-document navigation. 1905 + if target_url.equals_ignoring_fragment(&state.page.base_url) { 1906 + let fragment = target_url.fragment().unwrap_or("").to_string(); 1907 + navigate_fragment(state, target_url, &fragment); 1908 + return; 1909 + } 1910 + 1790 1911 eprintln!("[we] Navigating to: {}", target_url.serialize()); 1791 1912 1792 1913 // Save the current scroll position before navigating away. ··· 1831 1952 "[we] History traverse ({delta:+}): {}", 1832 1953 entry.url.serialize() 1833 1954 ); 1955 + 1956 + // If only the fragment changed, do a same-document traversal (restore 1957 + // scroll position from the history entry, no page reload). 1958 + if entry.url.equals_ignoring_fragment(&state.page.base_url) { 1959 + let old_url_str = state.page.base_url.serialize(); 1960 + let new_url_str = entry.url.serialize(); 1961 + state.page.base_url = entry.url; 1962 + state.page_scroll_y = entry.scroll_y; 1963 + rerender(state); 1964 + eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); 1965 + return; 1966 + } 1834 1967 1835 1968 let loaded = load_from_url(&entry.url); 1836 1969 let new_page = load_page(loaded);
+38
crates/browser/src/navigation_history.rs
··· 439 439 let e = h.traverse_back().unwrap(); 440 440 assert!(e.state.is_none()); // Initial entry has no state. 441 441 } 442 + 443 + #[test] 444 + fn fragment_navigation_creates_history_entry() { 445 + let mut h = NavigationHistory::new(url("https://example.com/page")); 446 + h.save_scroll(0.0, 100.0); 447 + h.push(url("https://example.com/page#section1")); 448 + assert_eq!(h.len(), 2); 449 + assert_eq!( 450 + h.current_entry().url.serialize(), 451 + "https://example.com/page#section1" 452 + ); 453 + 454 + // Traverse back restores original scroll position. 455 + let e = h.traverse_back().unwrap(); 456 + assert_eq!(e.url.serialize(), "https://example.com/page"); 457 + assert_eq!(e.scroll_y, 100.0); 458 + } 459 + 460 + #[test] 461 + fn multiple_fragment_navigations() { 462 + let mut h = NavigationHistory::new(url("https://example.com/page")); 463 + h.push(url("https://example.com/page#a")); 464 + h.push(url("https://example.com/page#b")); 465 + h.push(url("https://example.com/page#c")); 466 + assert_eq!(h.len(), 4); 467 + 468 + // Back through all fragments. 469 + let e = h.traverse_back().unwrap(); 470 + assert_eq!(e.url.fragment(), Some("b")); 471 + let e = h.traverse_back().unwrap(); 472 + assert_eq!(e.url.fragment(), Some("a")); 473 + let e = h.traverse_back().unwrap(); 474 + assert_eq!(e.url.fragment(), None); 475 + 476 + // Forward. 477 + let e = h.traverse_forward().unwrap(); 478 + assert_eq!(e.url.fragment(), Some("a")); 479 + } 442 480 }
+81
crates/dom/src/lib.rs
··· 554 554 None 555 555 } 556 556 557 + /// Find an `<a>` element by its `name` attribute (legacy fragment target). 558 + /// 559 + /// Per the HTML spec, if `getElementById` finds nothing, browsers fall back 560 + /// to the first `<a name="...">` whose name matches the fragment. 561 + pub fn find_anchor_by_name(&self, name: &str) -> Option<NodeId> { 562 + self.find_anchor_by_name_rec(self.root, name) 563 + } 564 + 565 + fn find_anchor_by_name_rec(&self, node: NodeId, name: &str) -> Option<NodeId> { 566 + if self.tag_name(node) == Some("a") && self.get_attribute(node, "name") == Some(name) { 567 + return Some(node); 568 + } 569 + let mut child = self.nodes[node.0].first_child; 570 + while let Some(c) = child { 571 + if let Some(found) = self.find_anchor_by_name_rec(c, name) { 572 + return Some(found); 573 + } 574 + child = self.nodes[c.0].next_sibling; 575 + } 576 + None 577 + } 578 + 557 579 fn first_form_control_descendant(&self, node: NodeId) -> Option<NodeId> { 558 580 let mut child = self.nodes[node.0].first_child; 559 581 while let Some(id) = child { ··· 2142 2164 2143 2165 let options = doc.select_options(select); 2144 2166 assert_eq!(options[0].value, "Hello"); 2167 + } 2168 + 2169 + // --- Fragment target lookup --- 2170 + 2171 + #[test] 2172 + fn find_anchor_by_name_finds_matching_anchor() { 2173 + let mut doc = Document::new(); 2174 + let root = doc.root(); 2175 + let body = doc.create_element("body"); 2176 + doc.append_child(root, body); 2177 + 2178 + let anchor = doc.create_element("a"); 2179 + doc.set_attribute(anchor, "name", "section1"); 2180 + doc.append_child(body, anchor); 2181 + 2182 + assert_eq!(doc.find_anchor_by_name("section1"), Some(anchor)); 2183 + } 2184 + 2185 + #[test] 2186 + fn find_anchor_by_name_ignores_non_anchor() { 2187 + let mut doc = Document::new(); 2188 + let root = doc.root(); 2189 + let body = doc.create_element("body"); 2190 + doc.append_child(root, body); 2191 + 2192 + // A <div> with name="section1" should NOT be found. 2193 + let div = doc.create_element("div"); 2194 + doc.set_attribute(div, "name", "section1"); 2195 + doc.append_child(body, div); 2196 + 2197 + assert_eq!(doc.find_anchor_by_name("section1"), None); 2198 + } 2199 + 2200 + #[test] 2201 + fn find_anchor_by_name_returns_none_for_missing() { 2202 + let doc = Document::new(); 2203 + assert_eq!(doc.find_anchor_by_name("nonexistent"), None); 2204 + } 2205 + 2206 + #[test] 2207 + fn get_element_by_id_before_anchor_by_name() { 2208 + let mut doc = Document::new(); 2209 + let root = doc.root(); 2210 + let body = doc.create_element("body"); 2211 + doc.append_child(root, body); 2212 + 2213 + // Both an element with id and an anchor with name. 2214 + let div = doc.create_element("div"); 2215 + doc.set_attribute(div, "id", "target"); 2216 + doc.append_child(body, div); 2217 + 2218 + let anchor = doc.create_element("a"); 2219 + doc.set_attribute(anchor, "name", "target"); 2220 + doc.append_child(body, anchor); 2221 + 2222 + // get_element_by_id should find the div first. 2223 + assert_eq!(doc.get_element_by_id("target"), Some(div)); 2224 + // find_anchor_by_name should find the anchor. 2225 + assert_eq!(doc.find_anchor_by_name("target"), Some(anchor)); 2145 2226 } 2146 2227 }
+169
crates/js/src/history.rs
··· 424 424 gc.alloc(HeapObject::Object(obj)) 425 425 } 426 426 427 + /// Create a HashChangeEvent object with oldURL and newURL properties. 428 + pub fn create_hashchange_event( 429 + gc: &mut Gc<HeapObject>, 430 + shapes: &mut ShapeTable, 431 + old_url: &str, 432 + new_url: &str, 433 + ) -> GcRef { 434 + let mut obj = ObjectData::new(); 435 + 436 + // Standard Event properties. 437 + obj.insert_property( 438 + "type".to_string(), 439 + Property::data(Value::String("hashchange".to_string())), 440 + shapes, 441 + ); 442 + obj.insert_property( 443 + "bubbles".to_string(), 444 + Property::data(Value::Boolean(true)), 445 + shapes, 446 + ); 447 + obj.insert_property( 448 + "cancelable".to_string(), 449 + Property::data(Value::Boolean(false)), 450 + shapes, 451 + ); 452 + obj.insert_property( 453 + "defaultPrevented".to_string(), 454 + Property::data(Value::Boolean(false)), 455 + shapes, 456 + ); 457 + obj.insert_property( 458 + "eventPhase".to_string(), 459 + Property::data(Value::Number(0.0)), 460 + shapes, 461 + ); 462 + obj.insert_property("target".to_string(), Property::data(Value::Null), shapes); 463 + obj.insert_property( 464 + "currentTarget".to_string(), 465 + Property::data(Value::Null), 466 + shapes, 467 + ); 468 + obj.insert_property( 469 + "timeStamp".to_string(), 470 + Property::data(Value::Number(0.0)), 471 + shapes, 472 + ); 473 + 474 + // Internal event state keys. 475 + obj.insert_property( 476 + "__event_type__".to_string(), 477 + Property::builtin(Value::String("hashchange".to_string())), 478 + shapes, 479 + ); 480 + obj.insert_property( 481 + "__event_bubbles__".to_string(), 482 + Property::builtin(Value::Boolean(true)), 483 + shapes, 484 + ); 485 + obj.insert_property( 486 + "__event_cancelable__".to_string(), 487 + Property::builtin(Value::Boolean(false)), 488 + shapes, 489 + ); 490 + obj.insert_property( 491 + "__event_stop_prop__".to_string(), 492 + Property::builtin(Value::Boolean(false)), 493 + shapes, 494 + ); 495 + obj.insert_property( 496 + "__event_stop_immediate__".to_string(), 497 + Property::builtin(Value::Boolean(false)), 498 + shapes, 499 + ); 500 + obj.insert_property( 501 + "__event_default_prevented__".to_string(), 502 + Property::builtin(Value::Boolean(false)), 503 + shapes, 504 + ); 505 + obj.insert_property( 506 + "__event_phase__".to_string(), 507 + Property::builtin(Value::Number(0.0)), 508 + shapes, 509 + ); 510 + 511 + // HashChangeEvent-specific properties. 512 + obj.insert_property( 513 + "oldURL".to_string(), 514 + Property::data(Value::String(old_url.to_string())), 515 + shapes, 516 + ); 517 + obj.insert_property( 518 + "newURL".to_string(), 519 + Property::data(Value::String(new_url.to_string())), 520 + shapes, 521 + ); 522 + 523 + gc.alloc(HeapObject::Object(obj)) 524 + } 525 + 427 526 // --------------------------------------------------------------------------- 428 527 // Tests 429 528 // --------------------------------------------------------------------------- ··· 553 652 )); 554 653 // Relative URLs are same-origin by definition. 555 654 assert!(is_same_origin("/relative/path", "https://example.com")); 655 + } 656 + 657 + #[test] 658 + fn hashchange_event_has_correct_properties() { 659 + let mut gc = Gc::<HeapObject>::new(); 660 + let mut shapes = ShapeTable::new(); 661 + let evt = create_hashchange_event( 662 + &mut gc, 663 + &mut shapes, 664 + "https://example.com/page", 665 + "https://example.com/page#section", 666 + ); 667 + 668 + if let Some(HeapObject::Object(data)) = gc.get(evt) { 669 + // Standard Event properties. 670 + match data 671 + .get_property("type", &shapes) 672 + .as_ref() 673 + .map(|p| &p.value) 674 + { 675 + Some(Value::String(s)) => assert_eq!(s, "hashchange"), 676 + other => panic!("expected 'hashchange', got {other:?}"), 677 + } 678 + match data 679 + .get_property("bubbles", &shapes) 680 + .as_ref() 681 + .map(|p| &p.value) 682 + { 683 + Some(Value::Boolean(true)) => {} 684 + other => panic!("expected true, got {other:?}"), 685 + } 686 + match data 687 + .get_property("cancelable", &shapes) 688 + .as_ref() 689 + .map(|p| &p.value) 690 + { 691 + Some(Value::Boolean(false)) => {} 692 + other => panic!("expected false, got {other:?}"), 693 + } 694 + 695 + // HashChangeEvent-specific properties. 696 + match data 697 + .get_property("oldURL", &shapes) 698 + .as_ref() 699 + .map(|p| &p.value) 700 + { 701 + Some(Value::String(s)) => assert_eq!(s, "https://example.com/page"), 702 + other => panic!("expected oldURL, got {other:?}"), 703 + } 704 + match data 705 + .get_property("newURL", &shapes) 706 + .as_ref() 707 + .map(|p| &p.value) 708 + { 709 + Some(Value::String(s)) => assert_eq!(s, "https://example.com/page#section"), 710 + other => panic!("expected newURL, got {other:?}"), 711 + } 712 + 713 + // Internal event type key. 714 + match data 715 + .get_property("__event_type__", &shapes) 716 + .as_ref() 717 + .map(|p| &p.value) 718 + { 719 + Some(Value::String(s)) => assert_eq!(s, "hashchange"), 720 + other => panic!("expected 'hashchange', got {other:?}"), 721 + } 722 + } else { 723 + panic!("expected Object"); 724 + } 556 725 } 557 726 }
+71
crates/url/src/lib.rs
··· 285 285 } 286 286 } 287 287 288 + /// Returns `true` if two URLs are identical except for the fragment. 289 + /// 290 + /// Used to detect same-document fragment navigation: if only the fragment 291 + /// differs, the browser should scroll rather than reload. 292 + pub fn equals_ignoring_fragment(&self, other: &Url) -> bool { 293 + self.scheme == other.scheme 294 + && self.username == other.username 295 + && self.password == other.password 296 + && self.host == other.host 297 + && self.port == other.port 298 + && self.path == other.path 299 + && self.opaque_path == other.opaque_path 300 + && self.query == other.query 301 + } 302 + 303 + /// Set the fragment (without leading '#'). Pass `None` to remove it. 304 + pub fn set_fragment(&mut self, fragment: Option<String>) { 305 + self.fragment = fragment; 306 + } 307 + 288 308 /// Serialize this URL to a string (the href). 289 309 pub fn serialize(&self) -> String { 290 310 let mut output = String::new(); ··· 2051 2071 fn percent_encode_multibyte_utf8() { 2052 2072 let encoded = percent_encode("café", is_path_encode); 2053 2073 assert_eq!(encoded, "caf%C3%A9"); 2074 + } 2075 + 2076 + // ------------------------------------------------------------------- 2077 + // equals_ignoring_fragment 2078 + // ------------------------------------------------------------------- 2079 + 2080 + #[test] 2081 + fn equals_ignoring_fragment_same_url_different_fragment() { 2082 + let a = Url::parse("https://example.com/page#sec1").unwrap(); 2083 + let b = Url::parse("https://example.com/page#sec2").unwrap(); 2084 + assert!(a.equals_ignoring_fragment(&b)); 2085 + } 2086 + 2087 + #[test] 2088 + fn equals_ignoring_fragment_no_fragment_vs_fragment() { 2089 + let a = Url::parse("https://example.com/page").unwrap(); 2090 + let b = Url::parse("https://example.com/page#sec").unwrap(); 2091 + assert!(a.equals_ignoring_fragment(&b)); 2092 + } 2093 + 2094 + #[test] 2095 + fn equals_ignoring_fragment_different_path() { 2096 + let a = Url::parse("https://example.com/a").unwrap(); 2097 + let b = Url::parse("https://example.com/b").unwrap(); 2098 + assert!(!a.equals_ignoring_fragment(&b)); 2099 + } 2100 + 2101 + #[test] 2102 + fn equals_ignoring_fragment_different_query() { 2103 + let a = Url::parse("https://example.com/page?q=1#frag").unwrap(); 2104 + let b = Url::parse("https://example.com/page?q=2#frag").unwrap(); 2105 + assert!(!a.equals_ignoring_fragment(&b)); 2106 + } 2107 + 2108 + #[test] 2109 + fn equals_ignoring_fragment_different_host() { 2110 + let a = Url::parse("https://a.com/page#frag").unwrap(); 2111 + let b = Url::parse("https://b.com/page#frag").unwrap(); 2112 + assert!(!a.equals_ignoring_fragment(&b)); 2113 + } 2114 + 2115 + #[test] 2116 + fn set_fragment_updates_url() { 2117 + let mut url = Url::parse("https://example.com/page").unwrap(); 2118 + assert_eq!(url.fragment(), None); 2119 + url.set_fragment(Some("section".to_string())); 2120 + assert_eq!(url.fragment(), Some("section")); 2121 + assert_eq!(url.serialize(), "https://example.com/page#section"); 2122 + url.set_fragment(None); 2123 + assert_eq!(url.fragment(), None); 2124 + assert_eq!(url.serialize(), "https://example.com/page"); 2054 2125 } 2055 2126 }