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

Implement DOM-JS bridge: node manipulation and properties

Add comprehensive DOM node manipulation methods and property access
from JavaScript, building on the existing DOM-JS bridge architecture.

Tree manipulation: appendChild, removeChild, insertBefore, replaceChild,
cloneNode, hasChildNodes. Content properties: textContent (get/set),
innerHTML (get/set with HTML parsing), outerHTML. Attribute methods:
getAttribute, setAttribute, removeAttribute, hasAttribute, attributes.
Navigation: parentNode, parentElement, childNodes, children, firstChild,
lastChild, firstElementChild, lastElementChild, nextSibling,
previousSibling, nextElementSibling, previousElementSibling.
Style: element.style object with camelCase CSS property access that
syncs to the inline style attribute. classList: add, remove, toggle,
contains methods that modify the class attribute.

Dynamic properties are resolved via VM interception in GetProperty and
SetProperty handlers, ensuring navigation and content properties always
reflect the current DOM state after modifications.

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

+1759 -39
+77
crates/dom/src/lib.rs
··· 298 298 self.nodes.len() == 1 299 299 } 300 300 301 + /// Returns true if `node` has any child nodes. 302 + pub fn has_child_nodes(&self, node: NodeId) -> bool { 303 + self.nodes[node.0].first_child.is_some() 304 + } 305 + 306 + /// Replace `old_child` under `parent` with `new_child`. 307 + /// 308 + /// If `new_child` is already attached elsewhere, it is first removed. 309 + /// Panics if `old_child` is not a child of `parent`. 310 + pub fn replace_child(&mut self, parent: NodeId, new_child: NodeId, old_child: NodeId) { 311 + assert_eq!( 312 + self.nodes[old_child.0].parent, 313 + Some(parent), 314 + "old_child is not a child of parent" 315 + ); 316 + self.insert_before(parent, new_child, old_child); 317 + self.detach(old_child); 318 + } 319 + 320 + /// Recursively collect all descendant text content. 321 + /// 322 + /// For Text/Comment nodes, returns their data. For Element/Document nodes, 323 + /// concatenates the text content of all descendant Text nodes. 324 + pub fn deep_text_content(&self, node: NodeId) -> String { 325 + let mut result = String::new(); 326 + self.collect_text(node, &mut result); 327 + result 328 + } 329 + 330 + /// Replace all children of `node` with a single text node containing `text`. 331 + /// If `text` is empty, all children are removed with no replacement. 332 + pub fn set_element_text_content(&mut self, node: NodeId, text: &str) { 333 + // Remove all existing children. 334 + while let Some(child) = self.first_child(node) { 335 + self.detach(child); 336 + } 337 + if !text.is_empty() { 338 + let text_node = self.create_text(text); 339 + self.append_child(node, text_node); 340 + } 341 + } 342 + 343 + /// Clone a node. If `deep` is true, recursively clone all descendants. 344 + pub fn clone_node(&mut self, node: NodeId, deep: bool) -> NodeId { 345 + let data = self.nodes[node.0].data.clone(); 346 + let new_node = self.push_node(data); 347 + if deep { 348 + let children: Vec<NodeId> = self.children(node).collect(); 349 + for child in children { 350 + let cloned_child = self.clone_node(child, true); 351 + self.append_child(new_node, cloned_child); 352 + } 353 + } 354 + new_node 355 + } 356 + 357 + /// Returns the attributes of an element node, or `None` for other node types. 358 + pub fn attributes(&self, node: NodeId) -> Option<&[Attribute]> { 359 + match &self.nodes[node.0].data { 360 + NodeData::Element { attributes, .. } => Some(attributes), 361 + _ => None, 362 + } 363 + } 364 + 301 365 // --- private helpers --- 366 + 367 + fn collect_text(&self, node: NodeId, result: &mut String) { 368 + match &self.nodes[node.0].data { 369 + NodeData::Text { data } => result.push_str(data), 370 + _ => { 371 + let mut child = self.nodes[node.0].first_child; 372 + while let Some(c) = child { 373 + self.collect_text(c, result); 374 + child = self.nodes[c.0].next_sibling; 375 + } 376 + } 377 + } 378 + } 302 379 303 380 fn push_node(&mut self, data: NodeData) -> NodeId { 304 381 let id = NodeId(self.nodes.len());
+1 -3
crates/js/Cargo.toml
··· 10 10 [dependencies] 11 11 we-dom = { path = "../dom" } 12 12 we-css = { path = "../css" } 13 - we-style = { path = "../style" } 14 - 15 - [dev-dependencies] 16 13 we-html = { path = "../html" } 14 + we-style = { path = "../style" }
+1583
crates/js/src/dom_bridge.rs
··· 7 7 use crate::builtins::{make_native, set_builtin_prop}; 8 8 use crate::gc::{Gc, GcRef}; 9 9 use crate::vm::*; 10 + use std::rc::Rc; 10 11 use we_css::parser::Parser as CssParser; 11 12 use we_dom::{Document, NodeData, NodeId}; 13 + use we_html::parse_html; 12 14 use we_style::matching::matches_selector_list; 13 15 14 16 /// Native callback type alias. ··· 123 125 } 124 126 125 127 let gc_ref = gc.alloc(HeapObject::Object(data)); 128 + 129 + // Register DOM methods on the wrapper based on node type. 130 + register_node_methods(gc, gc_ref, &doc, node_id); 131 + 126 132 bridge.node_wrappers.borrow_mut().insert(idx, gc_ref); 127 133 gc_ref 128 134 } ··· 504 510 Value::Object(gc.alloc(HeapObject::Object(obj))) 505 511 } 506 512 513 + // ── Method registration on wrappers ────────────────────────────────── 514 + 515 + /// Register DOM methods on a wrapper object based on node type. 516 + fn register_node_methods(gc: &mut Gc<HeapObject>, wrapper: GcRef, doc: &Document, node_id: NodeId) { 517 + // Methods available on all node types. 518 + let node_methods: &[NativeMethod] = &[ 519 + ("appendChild", node_append_child), 520 + ("removeChild", node_remove_child), 521 + ("insertBefore", node_insert_before), 522 + ("replaceChild", node_replace_child), 523 + ("cloneNode", node_clone_node), 524 + ("hasChildNodes", node_has_child_nodes), 525 + ]; 526 + for &(name, callback) in node_methods { 527 + let func = make_native(gc, name, callback); 528 + set_builtin_prop(gc, wrapper, name, Value::Function(func)); 529 + } 530 + 531 + // Methods available only on Element nodes. 532 + if matches!(doc.node_data(node_id), NodeData::Element { .. }) { 533 + let element_methods: &[NativeMethod] = &[ 534 + ("getAttribute", element_get_attribute), 535 + ("setAttribute", element_set_attribute), 536 + ("removeAttribute", element_remove_attribute), 537 + ("hasAttribute", element_has_attribute), 538 + ]; 539 + for &(name, callback) in element_methods { 540 + let func = make_native(gc, name, callback); 541 + set_builtin_prop(gc, wrapper, name, Value::Function(func)); 542 + } 543 + } 544 + } 545 + 546 + // ── Helper: extract NodeId from a wrapper ─────────────────────────── 547 + 548 + fn get_node_id(gc: &Gc<HeapObject>, wrapper: GcRef) -> Option<NodeId> { 549 + match gc.get(wrapper) { 550 + Some(HeapObject::Object(data)) => match data.properties.get(NODE_ID_KEY) { 551 + Some(Property { 552 + value: Value::Number(n), 553 + .. 554 + }) => Some(NodeId::from_index(*n as usize)), 555 + _ => None, 556 + }, 557 + _ => None, 558 + } 559 + } 560 + 561 + /// Extract a `NodeId` from a JS Value that should be a DOM wrapper object. 562 + fn value_to_node_id(gc: &Gc<HeapObject>, val: &Value) -> Option<NodeId> { 563 + match val { 564 + Value::Object(r) => get_node_id(gc, *r), 565 + _ => None, 566 + } 567 + } 568 + 569 + // ── Node manipulation methods ─────────────────────────────────────── 570 + 571 + fn node_append_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 572 + let bridge = ctx 573 + .dom_bridge 574 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 575 + let parent_id = match &ctx.this { 576 + Value::Object(r) => get_node_id(ctx.gc, *r), 577 + _ => None, 578 + } 579 + .ok_or_else(|| RuntimeError::type_error("appendChild called on non-node"))?; 580 + let child_val = args 581 + .first() 582 + .ok_or_else(|| RuntimeError::type_error("appendChild requires an argument"))?; 583 + let child_id = value_to_node_id(ctx.gc, child_val) 584 + .ok_or_else(|| RuntimeError::type_error("appendChild argument is not a node"))?; 585 + 586 + bridge 587 + .document 588 + .borrow_mut() 589 + .append_child(parent_id, child_id); 590 + Ok(child_val.clone()) 591 + } 592 + 593 + fn node_remove_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 594 + let bridge = ctx 595 + .dom_bridge 596 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 597 + let parent_id = match &ctx.this { 598 + Value::Object(r) => get_node_id(ctx.gc, *r), 599 + _ => None, 600 + } 601 + .ok_or_else(|| RuntimeError::type_error("removeChild called on non-node"))?; 602 + let child_val = args 603 + .first() 604 + .ok_or_else(|| RuntimeError::type_error("removeChild requires an argument"))?; 605 + let child_id = value_to_node_id(ctx.gc, child_val) 606 + .ok_or_else(|| RuntimeError::type_error("removeChild argument is not a node"))?; 607 + 608 + bridge 609 + .document 610 + .borrow_mut() 611 + .remove_child(parent_id, child_id); 612 + Ok(child_val.clone()) 613 + } 614 + 615 + fn node_insert_before(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 616 + let bridge = ctx 617 + .dom_bridge 618 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 619 + let parent_id = match &ctx.this { 620 + Value::Object(r) => get_node_id(ctx.gc, *r), 621 + _ => None, 622 + } 623 + .ok_or_else(|| RuntimeError::type_error("insertBefore called on non-node"))?; 624 + let new_node_val = args 625 + .first() 626 + .ok_or_else(|| RuntimeError::type_error("insertBefore requires two arguments"))?; 627 + let new_node_id = value_to_node_id(ctx.gc, new_node_val) 628 + .ok_or_else(|| RuntimeError::type_error("insertBefore: first argument is not a node"))?; 629 + 630 + let ref_val = args.get(1).cloned().unwrap_or(Value::Null); 631 + if matches!(ref_val, Value::Null) { 632 + // insertBefore with null reference = appendChild 633 + bridge 634 + .document 635 + .borrow_mut() 636 + .append_child(parent_id, new_node_id); 637 + } else { 638 + let ref_id = value_to_node_id(ctx.gc, &ref_val).ok_or_else(|| { 639 + RuntimeError::type_error("insertBefore: second argument is not a node") 640 + })?; 641 + bridge 642 + .document 643 + .borrow_mut() 644 + .insert_before(parent_id, new_node_id, ref_id); 645 + } 646 + Ok(new_node_val.clone()) 647 + } 648 + 649 + fn node_replace_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 650 + let bridge = ctx 651 + .dom_bridge 652 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 653 + let parent_id = match &ctx.this { 654 + Value::Object(r) => get_node_id(ctx.gc, *r), 655 + _ => None, 656 + } 657 + .ok_or_else(|| RuntimeError::type_error("replaceChild called on non-node"))?; 658 + let new_child_val = args 659 + .first() 660 + .ok_or_else(|| RuntimeError::type_error("replaceChild requires two arguments"))?; 661 + let new_child_id = value_to_node_id(ctx.gc, new_child_val) 662 + .ok_or_else(|| RuntimeError::type_error("replaceChild: first argument is not a node"))?; 663 + let old_child_val = args 664 + .get(1) 665 + .ok_or_else(|| RuntimeError::type_error("replaceChild requires two arguments"))?; 666 + let old_child_id = value_to_node_id(ctx.gc, old_child_val) 667 + .ok_or_else(|| RuntimeError::type_error("replaceChild: second argument is not a node"))?; 668 + 669 + bridge 670 + .document 671 + .borrow_mut() 672 + .replace_child(parent_id, new_child_id, old_child_id); 673 + Ok(old_child_val.clone()) 674 + } 675 + 676 + fn node_clone_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 677 + let bridge = ctx 678 + .dom_bridge 679 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 680 + let node_id = match &ctx.this { 681 + Value::Object(r) => get_node_id(ctx.gc, *r), 682 + _ => None, 683 + } 684 + .ok_or_else(|| RuntimeError::type_error("cloneNode called on non-node"))?; 685 + 686 + let deep = args.first().map(|v| v.to_boolean()).unwrap_or(false); 687 + 688 + let cloned_id = bridge.document.borrow_mut().clone_node(node_id, deep); 689 + let wrapper = get_or_create_wrapper(cloned_id, ctx.gc, bridge, None); 690 + Ok(Value::Object(wrapper)) 691 + } 692 + 693 + fn node_has_child_nodes(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 694 + let _ = args; 695 + let bridge = ctx 696 + .dom_bridge 697 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 698 + let node_id = match &ctx.this { 699 + Value::Object(r) => get_node_id(ctx.gc, *r), 700 + _ => None, 701 + } 702 + .ok_or_else(|| RuntimeError::type_error("hasChildNodes called on non-node"))?; 703 + 704 + let has = bridge.document.borrow().has_child_nodes(node_id); 705 + Ok(Value::Boolean(has)) 706 + } 707 + 708 + // ── Attribute methods ─────────────────────────────────────────────── 709 + 710 + fn element_get_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 711 + let bridge = ctx 712 + .dom_bridge 713 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 714 + let node_id = match &ctx.this { 715 + Value::Object(r) => get_node_id(ctx.gc, *r), 716 + _ => None, 717 + } 718 + .ok_or_else(|| RuntimeError::type_error("getAttribute called on non-element"))?; 719 + let name = args 720 + .first() 721 + .map(|v| v.to_js_string(ctx.gc)) 722 + .unwrap_or_default(); 723 + 724 + let doc = bridge.document.borrow(); 725 + match doc.get_attribute(node_id, &name) { 726 + Some(val) => Ok(Value::String(val.to_string())), 727 + None => Ok(Value::Null), 728 + } 729 + } 730 + 731 + fn element_set_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 732 + let bridge = ctx 733 + .dom_bridge 734 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 735 + let node_id = match &ctx.this { 736 + Value::Object(r) => get_node_id(ctx.gc, *r), 737 + _ => None, 738 + } 739 + .ok_or_else(|| RuntimeError::type_error("setAttribute called on non-element"))?; 740 + let name = args 741 + .first() 742 + .map(|v| v.to_js_string(ctx.gc)) 743 + .unwrap_or_default(); 744 + let value = args 745 + .get(1) 746 + .map(|v| v.to_js_string(ctx.gc)) 747 + .unwrap_or_default(); 748 + 749 + bridge 750 + .document 751 + .borrow_mut() 752 + .set_attribute(node_id, &name, &value); 753 + 754 + // Sync special attributes to wrapper properties. 755 + if let Value::Object(wrapper) = &ctx.this { 756 + if name == "id" { 757 + set_builtin_prop(ctx.gc, *wrapper, "id", Value::String(value.clone())); 758 + } else if name == "class" { 759 + set_builtin_prop(ctx.gc, *wrapper, "className", Value::String(value.clone())); 760 + } 761 + } 762 + 763 + Ok(Value::Undefined) 764 + } 765 + 766 + fn element_remove_attribute( 767 + args: &[Value], 768 + ctx: &mut NativeContext, 769 + ) -> Result<Value, RuntimeError> { 770 + let bridge = ctx 771 + .dom_bridge 772 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 773 + let node_id = match &ctx.this { 774 + Value::Object(r) => get_node_id(ctx.gc, *r), 775 + _ => None, 776 + } 777 + .ok_or_else(|| RuntimeError::type_error("removeAttribute called on non-element"))?; 778 + let name = args 779 + .first() 780 + .map(|v| v.to_js_string(ctx.gc)) 781 + .unwrap_or_default(); 782 + 783 + bridge 784 + .document 785 + .borrow_mut() 786 + .remove_attribute(node_id, &name); 787 + 788 + // Sync to wrapper. 789 + if let Value::Object(wrapper) = &ctx.this { 790 + if name == "id" { 791 + set_builtin_prop(ctx.gc, *wrapper, "id", Value::String(String::new())); 792 + } else if name == "class" { 793 + set_builtin_prop(ctx.gc, *wrapper, "className", Value::String(String::new())); 794 + } 795 + } 796 + 797 + Ok(Value::Undefined) 798 + } 799 + 800 + fn element_has_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 801 + let bridge = ctx 802 + .dom_bridge 803 + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; 804 + let node_id = match &ctx.this { 805 + Value::Object(r) => get_node_id(ctx.gc, *r), 806 + _ => None, 807 + } 808 + .ok_or_else(|| RuntimeError::type_error("hasAttribute called on non-element"))?; 809 + let name = args 810 + .first() 811 + .map(|v| v.to_js_string(ctx.gc)) 812 + .unwrap_or_default(); 813 + 814 + let doc = bridge.document.borrow(); 815 + let has = doc.get_attribute(node_id, &name).is_some(); 816 + Ok(Value::Boolean(has)) 817 + } 818 + 819 + // ── HTML serialization ────────────────────────────────────────────── 820 + 821 + /// Serialize the children of a node to HTML (for innerHTML getter). 822 + fn serialize_children(doc: &Document, node: NodeId) -> String { 823 + let mut html = String::new(); 824 + for child in doc.children(node) { 825 + serialize_node_to(doc, child, &mut html); 826 + } 827 + html 828 + } 829 + 830 + /// Serialize a node and its subtree to HTML (for outerHTML getter). 831 + fn serialize_node_html(doc: &Document, node: NodeId) -> String { 832 + let mut html = String::new(); 833 + serialize_node_to(doc, node, &mut html); 834 + html 835 + } 836 + 837 + fn serialize_node_to(doc: &Document, node: NodeId, out: &mut String) { 838 + match doc.node_data(node) { 839 + NodeData::Element { 840 + tag_name, 841 + attributes, 842 + .. 843 + } => { 844 + out.push('<'); 845 + out.push_str(tag_name); 846 + for attr in attributes { 847 + out.push(' '); 848 + out.push_str(&attr.name); 849 + out.push_str("=\""); 850 + escape_attr(&attr.value, out); 851 + out.push('"'); 852 + } 853 + out.push('>'); 854 + 855 + // Void elements don't have closing tags. 856 + if !is_void_element(tag_name) { 857 + for child in doc.children(node) { 858 + serialize_node_to(doc, child, out); 859 + } 860 + out.push_str("</"); 861 + out.push_str(tag_name); 862 + out.push('>'); 863 + } 864 + } 865 + NodeData::Text { data } => { 866 + escape_text(data, out); 867 + } 868 + NodeData::Comment { data } => { 869 + out.push_str("<!--"); 870 + out.push_str(data); 871 + out.push_str("-->"); 872 + } 873 + NodeData::Document => { 874 + for child in doc.children(node) { 875 + serialize_node_to(doc, child, out); 876 + } 877 + } 878 + } 879 + } 880 + 881 + fn escape_text(s: &str, out: &mut String) { 882 + for c in s.chars() { 883 + match c { 884 + '&' => out.push_str("&amp;"), 885 + '<' => out.push_str("&lt;"), 886 + '>' => out.push_str("&gt;"), 887 + _ => out.push(c), 888 + } 889 + } 890 + } 891 + 892 + fn escape_attr(s: &str, out: &mut String) { 893 + for c in s.chars() { 894 + match c { 895 + '&' => out.push_str("&amp;"), 896 + '"' => out.push_str("&quot;"), 897 + _ => out.push(c), 898 + } 899 + } 900 + } 901 + 902 + fn is_void_element(tag: &str) -> bool { 903 + matches!( 904 + tag.to_ascii_lowercase().as_str(), 905 + "area" 906 + | "base" 907 + | "br" 908 + | "col" 909 + | "embed" 910 + | "hr" 911 + | "img" 912 + | "input" 913 + | "link" 914 + | "meta" 915 + | "param" 916 + | "source" 917 + | "track" 918 + | "wbr" 919 + ) 920 + } 921 + 922 + // ── Dynamic DOM property resolution ───────────────────────────────── 923 + 924 + /// Resolve a dynamic DOM property for a wrapper object. 925 + /// Called from the VM when a property is not found in the static properties. 926 + /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. 927 + pub fn resolve_dom_get( 928 + gc: &mut Gc<HeapObject>, 929 + bridge: &Rc<DomBridge>, 930 + gc_ref: GcRef, 931 + key: &str, 932 + ) -> Option<Value> { 933 + let node_id = get_node_id(gc, gc_ref)?; 934 + let doc = bridge.document.borrow(); 935 + 936 + match key { 937 + // ── Navigation properties ──────────────── 938 + "parentNode" => { 939 + let parent = doc.parent(node_id); 940 + drop(doc); 941 + Some(match parent { 942 + Some(p) => Value::Object(get_or_create_wrapper(p, gc, bridge, None)), 943 + None => Value::Null, 944 + }) 945 + } 946 + "parentElement" => { 947 + let parent = doc.parent(node_id); 948 + let is_element = parent 949 + .map(|p| matches!(doc.node_data(p), NodeData::Element { .. })) 950 + .unwrap_or(false); 951 + drop(doc); 952 + Some(if is_element { 953 + Value::Object(get_or_create_wrapper(parent.unwrap(), gc, bridge, None)) 954 + } else { 955 + Value::Null 956 + }) 957 + } 958 + "childNodes" => { 959 + let children: Vec<NodeId> = doc.children(node_id).collect(); 960 + drop(doc); 961 + Some(make_wrapper_array(&children, gc, bridge, None)) 962 + } 963 + "children" => { 964 + let children: Vec<NodeId> = doc 965 + .children(node_id) 966 + .filter(|&c| matches!(doc.node_data(c), NodeData::Element { .. })) 967 + .collect(); 968 + drop(doc); 969 + Some(make_wrapper_array(&children, gc, bridge, None)) 970 + } 971 + "firstChild" => { 972 + let fc = doc.first_child(node_id); 973 + drop(doc); 974 + Some(match fc { 975 + Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), 976 + None => Value::Null, 977 + }) 978 + } 979 + "lastChild" => { 980 + let lc = doc.last_child(node_id); 981 + drop(doc); 982 + Some(match lc { 983 + Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), 984 + None => Value::Null, 985 + }) 986 + } 987 + "firstElementChild" => { 988 + let fc = doc 989 + .children(node_id) 990 + .find(|&c| matches!(doc.node_data(c), NodeData::Element { .. })); 991 + drop(doc); 992 + Some(match fc { 993 + Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), 994 + None => Value::Null, 995 + }) 996 + } 997 + "lastElementChild" => { 998 + let children: Vec<NodeId> = doc 999 + .children(node_id) 1000 + .filter(|&c| matches!(doc.node_data(c), NodeData::Element { .. })) 1001 + .collect(); 1002 + let last = children.last().copied(); 1003 + drop(doc); 1004 + Some(match last { 1005 + Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), 1006 + None => Value::Null, 1007 + }) 1008 + } 1009 + "nextSibling" => { 1010 + let ns = doc.next_sibling(node_id); 1011 + drop(doc); 1012 + Some(match ns { 1013 + Some(s) => Value::Object(get_or_create_wrapper(s, gc, bridge, None)), 1014 + None => Value::Null, 1015 + }) 1016 + } 1017 + "previousSibling" => { 1018 + let ps = doc.prev_sibling(node_id); 1019 + drop(doc); 1020 + Some(match ps { 1021 + Some(s) => Value::Object(get_or_create_wrapper(s, gc, bridge, None)), 1022 + None => Value::Null, 1023 + }) 1024 + } 1025 + "nextElementSibling" => { 1026 + let mut current = doc.next_sibling(node_id); 1027 + while let Some(s) = current { 1028 + if matches!(doc.node_data(s), NodeData::Element { .. }) { 1029 + drop(doc); 1030 + return Some(Value::Object(get_or_create_wrapper(s, gc, bridge, None))); 1031 + } 1032 + current = doc.next_sibling(s); 1033 + } 1034 + Some(Value::Null) 1035 + } 1036 + "previousElementSibling" => { 1037 + let mut current = doc.prev_sibling(node_id); 1038 + while let Some(s) = current { 1039 + if matches!(doc.node_data(s), NodeData::Element { .. }) { 1040 + drop(doc); 1041 + return Some(Value::Object(get_or_create_wrapper(s, gc, bridge, None))); 1042 + } 1043 + current = doc.prev_sibling(s); 1044 + } 1045 + Some(Value::Null) 1046 + } 1047 + 1048 + // ── Content properties ─────────────────── 1049 + "textContent" => { 1050 + let text = doc.deep_text_content(node_id); 1051 + Some(Value::String(text)) 1052 + } 1053 + "innerHTML" => { 1054 + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { 1055 + return None; 1056 + } 1057 + let html = serialize_children(&doc, node_id); 1058 + Some(Value::String(html)) 1059 + } 1060 + "outerHTML" => { 1061 + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { 1062 + return None; 1063 + } 1064 + let html = serialize_node_html(&doc, node_id); 1065 + Some(Value::String(html)) 1066 + } 1067 + 1068 + // ── Attribute array ────────────────────── 1069 + "attributes" => { 1070 + if let Some(attrs) = doc.attributes(node_id) { 1071 + let mut obj = ObjectData::new(); 1072 + for (i, attr) in attrs.iter().enumerate() { 1073 + let mut attr_obj = ObjectData::new(); 1074 + attr_obj.properties.insert( 1075 + "name".to_string(), 1076 + Property::data(Value::String(attr.name.clone())), 1077 + ); 1078 + attr_obj.properties.insert( 1079 + "value".to_string(), 1080 + Property::data(Value::String(attr.value.clone())), 1081 + ); 1082 + let attr_ref = gc.alloc(HeapObject::Object(attr_obj)); 1083 + obj.properties 1084 + .insert(i.to_string(), Property::data(Value::Object(attr_ref))); 1085 + } 1086 + obj.properties.insert( 1087 + "length".to_string(), 1088 + Property { 1089 + value: Value::Number(doc.attributes(node_id).map_or(0, |a| a.len()) as f64), 1090 + writable: false, 1091 + enumerable: false, 1092 + configurable: false, 1093 + }, 1094 + ); 1095 + drop(doc); 1096 + Some(Value::Object(gc.alloc(HeapObject::Object(obj)))) 1097 + } else { 1098 + None 1099 + } 1100 + } 1101 + 1102 + // ── classList ──────────────────────────── 1103 + "classList" => { 1104 + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { 1105 + return None; 1106 + } 1107 + drop(doc); 1108 + Some(create_class_list(gc, bridge, node_id)) 1109 + } 1110 + 1111 + // ── style ──────────────────────────────── 1112 + "style" => { 1113 + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { 1114 + return None; 1115 + } 1116 + let style_str = doc 1117 + .get_attribute(node_id, "style") 1118 + .unwrap_or("") 1119 + .to_string(); 1120 + drop(doc); 1121 + Some(create_style_object(gc, bridge, node_id, &style_str)) 1122 + } 1123 + 1124 + _ => None, 1125 + } 1126 + } 1127 + 1128 + /// Handle a DOM property set on a wrapper object. 1129 + /// Returns `true` if the key was handled (caller should skip normal property set). 1130 + pub fn handle_dom_set( 1131 + gc: &mut Gc<HeapObject>, 1132 + bridge: &Rc<DomBridge>, 1133 + gc_ref: GcRef, 1134 + key: &str, 1135 + val: &Value, 1136 + ) -> bool { 1137 + let node_id = match get_node_id(gc, gc_ref) { 1138 + Some(id) => id, 1139 + None => return false, 1140 + }; 1141 + 1142 + match key { 1143 + "textContent" => { 1144 + let text = val.to_js_string(gc); 1145 + bridge 1146 + .document 1147 + .borrow_mut() 1148 + .set_element_text_content(node_id, &text); 1149 + true 1150 + } 1151 + "innerHTML" => { 1152 + let html_str = val.to_js_string(gc); 1153 + let mut doc = bridge.document.borrow_mut(); 1154 + // Remove existing children. 1155 + while let Some(child) = doc.first_child(node_id) { 1156 + doc.remove_child(node_id, child); 1157 + } 1158 + drop(doc); 1159 + // Parse the HTML fragment and adopt children into the real document. 1160 + let fragment_doc = parse_html(&format!("<body>{html_str}</body>")); 1161 + let frag_root = fragment_doc.root(); 1162 + // Find the <body> element in the parsed fragment. 1163 + let body = fragment_doc.children(frag_root).find(|&c| { 1164 + matches!(fragment_doc.node_data(c), NodeData::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("html")) 1165 + }).and_then(|html| { 1166 + fragment_doc.children(html).find(|&c| { 1167 + matches!(fragment_doc.node_data(c), NodeData::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("body")) 1168 + }) 1169 + }); 1170 + if let Some(body_id) = body { 1171 + adopt_children( 1172 + &fragment_doc, 1173 + body_id, 1174 + &mut bridge.document.borrow_mut(), 1175 + node_id, 1176 + ); 1177 + } 1178 + true 1179 + } 1180 + "id" => { 1181 + let id_val = val.to_js_string(gc); 1182 + bridge 1183 + .document 1184 + .borrow_mut() 1185 + .set_attribute(node_id, "id", &id_val); 1186 + // Also update the wrapper's static `id` property. 1187 + set_builtin_prop(gc, gc_ref, "id", Value::String(id_val)); 1188 + true 1189 + } 1190 + "className" => { 1191 + let class_val = val.to_js_string(gc); 1192 + bridge 1193 + .document 1194 + .borrow_mut() 1195 + .set_attribute(node_id, "class", &class_val); 1196 + set_builtin_prop(gc, gc_ref, "className", Value::String(class_val)); 1197 + true 1198 + } 1199 + _ => false, 1200 + } 1201 + } 1202 + 1203 + /// Recursively copy children from a parsed fragment document into the real document. 1204 + fn adopt_children( 1205 + src_doc: &Document, 1206 + src_parent: NodeId, 1207 + dst_doc: &mut Document, 1208 + dst_parent: NodeId, 1209 + ) { 1210 + for child in src_doc.children(src_parent) { 1211 + let new_node = match src_doc.node_data(child) { 1212 + NodeData::Element { 1213 + tag_name, 1214 + attributes, 1215 + .. 1216 + } => { 1217 + let el = dst_doc.create_element(tag_name); 1218 + for attr in attributes { 1219 + dst_doc.set_attribute(el, &attr.name, &attr.value); 1220 + } 1221 + el 1222 + } 1223 + NodeData::Text { data } => dst_doc.create_text(data), 1224 + NodeData::Comment { data } => dst_doc.create_comment(data), 1225 + NodeData::Document => continue, 1226 + }; 1227 + dst_doc.append_child(dst_parent, new_node); 1228 + // Recurse for element children. 1229 + if matches!(src_doc.node_data(child), NodeData::Element { .. }) { 1230 + adopt_children(src_doc, child, dst_doc, new_node); 1231 + } 1232 + } 1233 + } 1234 + 1235 + /// Check if a DOM property set should be intercepted for a style proxy object. 1236 + /// Returns `true` if handled. 1237 + pub fn handle_style_set( 1238 + gc: &mut Gc<HeapObject>, 1239 + bridge: &Rc<DomBridge>, 1240 + gc_ref: GcRef, 1241 + key: &str, 1242 + val: &Value, 1243 + ) -> bool { 1244 + // Check for __style_node_id__ marker. 1245 + let node_id = match gc.get(gc_ref) { 1246 + Some(HeapObject::Object(data)) => match data.properties.get("__style_node_id__") { 1247 + Some(Property { 1248 + value: Value::Number(n), 1249 + .. 1250 + }) => NodeId::from_index(*n as usize), 1251 + _ => return false, 1252 + }, 1253 + _ => return false, 1254 + }; 1255 + 1256 + // Set the property on the style object normally. 1257 + let val_str = val.to_js_string(gc); 1258 + if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { 1259 + data.properties 1260 + .insert(key.to_string(), Property::data(val.clone())); 1261 + } 1262 + 1263 + // Serialize all style properties back to the element's style attribute. 1264 + let style_str = serialize_style_object(gc, gc_ref); 1265 + bridge 1266 + .document 1267 + .borrow_mut() 1268 + .set_attribute(node_id, "style", &style_str); 1269 + 1270 + // Also set it as a normal property (already done above), but tell caller it was handled. 1271 + let _ = val_str; 1272 + true 1273 + } 1274 + 1275 + // ── classList helpers ─────────────────────────────────────────────── 1276 + 1277 + fn create_class_list(gc: &mut Gc<HeapObject>, bridge: &DomBridge, node_id: NodeId) -> Value { 1278 + let mut data = ObjectData::new(); 1279 + // Store the node ID for method callbacks. 1280 + data.properties.insert( 1281 + NODE_ID_KEY.to_string(), 1282 + Property::builtin(Value::Number(node_id.index() as f64)), 1283 + ); 1284 + 1285 + let gc_ref = gc.alloc(HeapObject::Object(data)); 1286 + 1287 + let methods: &[NativeMethod] = &[ 1288 + ("add", class_list_add), 1289 + ("remove", class_list_remove), 1290 + ("toggle", class_list_toggle), 1291 + ("contains", class_list_contains), 1292 + ]; 1293 + for &(name, callback) in methods { 1294 + let func = make_native(gc, name, callback); 1295 + set_builtin_prop(gc, gc_ref, name, Value::Function(func)); 1296 + } 1297 + 1298 + // Also keep a reference to the bridge node_wrappers to avoid issues. 1299 + // The classList methods use ctx.dom_bridge to access the document. 1300 + let _ = bridge; 1301 + Value::Object(gc_ref) 1302 + } 1303 + 1304 + fn get_class_list_node_id(gc: &Gc<HeapObject>, this: &Value) -> Option<NodeId> { 1305 + match this { 1306 + Value::Object(r) => get_node_id(gc, *r), 1307 + _ => None, 1308 + } 1309 + } 1310 + 1311 + fn class_list_add(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1312 + let bridge = ctx 1313 + .dom_bridge 1314 + .ok_or_else(|| RuntimeError::type_error("no document"))?; 1315 + let node_id = get_class_list_node_id(ctx.gc, &ctx.this) 1316 + .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; 1317 + 1318 + for arg in args { 1319 + let class_name = arg.to_js_string(ctx.gc); 1320 + let doc = bridge.document.borrow(); 1321 + let current = doc 1322 + .get_attribute(node_id, "class") 1323 + .unwrap_or("") 1324 + .to_string(); 1325 + drop(doc); 1326 + if !current.split_whitespace().any(|c| c == class_name) { 1327 + let new_val = if current.is_empty() { 1328 + class_name 1329 + } else { 1330 + format!("{current} {class_name}") 1331 + }; 1332 + bridge 1333 + .document 1334 + .borrow_mut() 1335 + .set_attribute(node_id, "class", &new_val); 1336 + } 1337 + } 1338 + Ok(Value::Undefined) 1339 + } 1340 + 1341 + fn class_list_remove(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1342 + let bridge = ctx 1343 + .dom_bridge 1344 + .ok_or_else(|| RuntimeError::type_error("no document"))?; 1345 + let node_id = get_class_list_node_id(ctx.gc, &ctx.this) 1346 + .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; 1347 + 1348 + for arg in args { 1349 + let class_name = arg.to_js_string(ctx.gc); 1350 + let doc = bridge.document.borrow(); 1351 + let current = doc 1352 + .get_attribute(node_id, "class") 1353 + .unwrap_or("") 1354 + .to_string(); 1355 + drop(doc); 1356 + let new_val: Vec<&str> = current 1357 + .split_whitespace() 1358 + .filter(|&c| c != class_name) 1359 + .collect(); 1360 + bridge 1361 + .document 1362 + .borrow_mut() 1363 + .set_attribute(node_id, "class", &new_val.join(" ")); 1364 + } 1365 + Ok(Value::Undefined) 1366 + } 1367 + 1368 + fn class_list_toggle(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1369 + let bridge = ctx 1370 + .dom_bridge 1371 + .ok_or_else(|| RuntimeError::type_error("no document"))?; 1372 + let node_id = get_class_list_node_id(ctx.gc, &ctx.this) 1373 + .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; 1374 + let class_name = args 1375 + .first() 1376 + .map(|v| v.to_js_string(ctx.gc)) 1377 + .unwrap_or_default(); 1378 + 1379 + let doc = bridge.document.borrow(); 1380 + let current = doc 1381 + .get_attribute(node_id, "class") 1382 + .unwrap_or("") 1383 + .to_string(); 1384 + drop(doc); 1385 + 1386 + let has_class = current.split_whitespace().any(|c| c == class_name); 1387 + if has_class { 1388 + let new_val: Vec<&str> = current 1389 + .split_whitespace() 1390 + .filter(|&c| c != class_name) 1391 + .collect(); 1392 + bridge 1393 + .document 1394 + .borrow_mut() 1395 + .set_attribute(node_id, "class", &new_val.join(" ")); 1396 + Ok(Value::Boolean(false)) 1397 + } else { 1398 + let new_val = if current.is_empty() { 1399 + class_name 1400 + } else { 1401 + format!("{current} {class_name}") 1402 + }; 1403 + bridge 1404 + .document 1405 + .borrow_mut() 1406 + .set_attribute(node_id, "class", &new_val); 1407 + Ok(Value::Boolean(true)) 1408 + } 1409 + } 1410 + 1411 + fn class_list_contains(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1412 + let bridge = ctx 1413 + .dom_bridge 1414 + .ok_or_else(|| RuntimeError::type_error("no document"))?; 1415 + let node_id = get_class_list_node_id(ctx.gc, &ctx.this) 1416 + .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; 1417 + let class_name = args 1418 + .first() 1419 + .map(|v| v.to_js_string(ctx.gc)) 1420 + .unwrap_or_default(); 1421 + 1422 + let doc = bridge.document.borrow(); 1423 + let current = doc.get_attribute(node_id, "class").unwrap_or(""); 1424 + let has = current.split_whitespace().any(|c| c == class_name); 1425 + Ok(Value::Boolean(has)) 1426 + } 1427 + 1428 + // ── Style object helpers ──────────────────────────────────────────── 1429 + 1430 + fn create_style_object( 1431 + gc: &mut Gc<HeapObject>, 1432 + _bridge: &DomBridge, 1433 + node_id: NodeId, 1434 + style_str: &str, 1435 + ) -> Value { 1436 + let mut data = ObjectData::new(); 1437 + // Marker for style proxy interception. 1438 + data.properties.insert( 1439 + "__style_node_id__".to_string(), 1440 + Property::builtin(Value::Number(node_id.index() as f64)), 1441 + ); 1442 + 1443 + // Parse existing inline style into camelCase properties. 1444 + for decl in style_str.split(';') { 1445 + let decl = decl.trim(); 1446 + if decl.is_empty() { 1447 + continue; 1448 + } 1449 + if let Some((prop, val)) = decl.split_once(':') { 1450 + let camel = kebab_to_camel(prop.trim()); 1451 + data.properties 1452 + .insert(camel, Property::data(Value::String(val.trim().to_string()))); 1453 + } 1454 + } 1455 + 1456 + Value::Object(gc.alloc(HeapObject::Object(data))) 1457 + } 1458 + 1459 + fn serialize_style_object(gc: &Gc<HeapObject>, style_ref: GcRef) -> String { 1460 + let mut parts = Vec::new(); 1461 + if let Some(HeapObject::Object(data)) = gc.get(style_ref) { 1462 + for (key, prop) in &data.properties { 1463 + if key.starts_with("__") { 1464 + continue; 1465 + } 1466 + let kebab = camel_to_kebab(key); 1467 + let val = match &prop.value { 1468 + Value::String(s) => s.clone(), 1469 + Value::Number(n) => format!("{n}"), 1470 + _ => continue, 1471 + }; 1472 + parts.push(format!("{kebab}: {val}")); 1473 + } 1474 + } 1475 + parts.join("; ") 1476 + } 1477 + 1478 + fn kebab_to_camel(s: &str) -> String { 1479 + let mut result = String::new(); 1480 + let mut next_upper = false; 1481 + for c in s.chars() { 1482 + if c == '-' { 1483 + next_upper = true; 1484 + } else if next_upper { 1485 + result.extend(c.to_uppercase()); 1486 + next_upper = false; 1487 + } else { 1488 + result.push(c); 1489 + } 1490 + } 1491 + result 1492 + } 1493 + 1494 + fn camel_to_kebab(s: &str) -> String { 1495 + let mut result = String::new(); 1496 + for c in s.chars() { 1497 + if c.is_ascii_uppercase() { 1498 + result.push('-'); 1499 + result.push(c.to_ascii_lowercase()); 1500 + } else { 1501 + result.push(c); 1502 + } 1503 + } 1504 + result 1505 + } 1506 + 507 1507 // ── Tests ─────────────────────────────────────────────────────────── 508 1508 509 1509 #[cfg(test)] ··· 761 1761 Value::Boolean(b) => assert!(b), 762 1762 v => panic!("expected true, got {v:?}"), 763 1763 } 1764 + } 1765 + 1766 + // ── appendChild tests ──────────────────────────────── 1767 + 1768 + #[test] 1769 + fn test_append_child_moves_node() { 1770 + let result = eval_with_doc( 1771 + r#"<html><body><div id="parent"></div></body></html>"#, 1772 + r#" 1773 + var parent = document.getElementById("parent"); 1774 + var child = document.createElement("span"); 1775 + parent.appendChild(child); 1776 + parent.hasChildNodes() 1777 + "#, 1778 + ) 1779 + .unwrap(); 1780 + assert!(matches!(result, Value::Boolean(true))); 1781 + } 1782 + 1783 + #[test] 1784 + fn test_append_child_returns_child() { 1785 + let result = eval_with_doc( 1786 + r#"<html><body><div id="p"></div></body></html>"#, 1787 + r#" 1788 + var p = document.getElementById("p"); 1789 + var c = document.createElement("span"); 1790 + var returned = p.appendChild(c); 1791 + returned === c 1792 + "#, 1793 + ) 1794 + .unwrap(); 1795 + assert!(matches!(result, Value::Boolean(true))); 1796 + } 1797 + 1798 + // ── removeChild tests ──────────────────────────────── 1799 + 1800 + #[test] 1801 + fn test_remove_child() { 1802 + let result = eval_with_doc( 1803 + r#"<html><body><div id="p"><span id="c"></span></div></body></html>"#, 1804 + r#" 1805 + var p = document.getElementById("p"); 1806 + var c = document.getElementById("c"); 1807 + p.removeChild(c); 1808 + p.hasChildNodes() 1809 + "#, 1810 + ) 1811 + .unwrap(); 1812 + assert!(matches!(result, Value::Boolean(false))); 1813 + } 1814 + 1815 + // ── insertBefore tests ─────────────────────────────── 1816 + 1817 + #[test] 1818 + fn test_insert_before() { 1819 + let result = eval_with_doc( 1820 + r#"<html><body><div id="p"><span id="ref"></span></div></body></html>"#, 1821 + r#" 1822 + var p = document.getElementById("p"); 1823 + var ref = document.getElementById("ref"); 1824 + var newNode = document.createElement("em"); 1825 + p.insertBefore(newNode, ref); 1826 + p.firstChild.tagName 1827 + "#, 1828 + ) 1829 + .unwrap(); 1830 + match result { 1831 + Value::String(s) => assert_eq!(s, "EM"), 1832 + v => panic!("expected 'EM', got {v:?}"), 1833 + } 1834 + } 1835 + 1836 + #[test] 1837 + fn test_insert_before_null_appends() { 1838 + let result = eval_with_doc( 1839 + r#"<html><body><div id="p"></div></body></html>"#, 1840 + r#" 1841 + var p = document.getElementById("p"); 1842 + var newNode = document.createElement("em"); 1843 + p.insertBefore(newNode, null); 1844 + p.firstChild.tagName 1845 + "#, 1846 + ) 1847 + .unwrap(); 1848 + match result { 1849 + Value::String(s) => assert_eq!(s, "EM"), 1850 + v => panic!("expected 'EM', got {v:?}"), 1851 + } 1852 + } 1853 + 1854 + // ── replaceChild tests ─────────────────────────────── 1855 + 1856 + #[test] 1857 + fn test_replace_child() { 1858 + let result = eval_with_doc( 1859 + r#"<html><body><div id="p"><span id="old"></span></div></body></html>"#, 1860 + r#" 1861 + var p = document.getElementById("p"); 1862 + var old = document.getElementById("old"); 1863 + var newEl = document.createElement("em"); 1864 + p.replaceChild(newEl, old); 1865 + p.firstChild.tagName 1866 + "#, 1867 + ) 1868 + .unwrap(); 1869 + match result { 1870 + Value::String(s) => assert_eq!(s, "EM"), 1871 + v => panic!("expected 'EM', got {v:?}"), 1872 + } 1873 + } 1874 + 1875 + // ── cloneNode tests ────────────────────────────────── 1876 + 1877 + #[test] 1878 + fn test_clone_node_shallow() { 1879 + let result = eval_with_doc( 1880 + r#"<html><body><div id="orig"><span>child</span></div></body></html>"#, 1881 + r#" 1882 + var orig = document.getElementById("orig"); 1883 + var clone = orig.cloneNode(false); 1884 + clone.hasChildNodes() 1885 + "#, 1886 + ) 1887 + .unwrap(); 1888 + assert!(matches!(result, Value::Boolean(false))); 1889 + } 1890 + 1891 + #[test] 1892 + fn test_clone_node_deep() { 1893 + let result = eval_with_doc( 1894 + r#"<html><body><div id="orig"><span>child</span></div></body></html>"#, 1895 + r#" 1896 + var orig = document.getElementById("orig"); 1897 + var clone = orig.cloneNode(true); 1898 + clone.hasChildNodes() 1899 + "#, 1900 + ) 1901 + .unwrap(); 1902 + assert!(matches!(result, Value::Boolean(true))); 1903 + } 1904 + 1905 + // ── textContent tests ──────────────────────────────── 1906 + 1907 + #[test] 1908 + fn test_text_content_get() { 1909 + let result = eval_with_doc( 1910 + r#"<html><body><div id="d">hello <span>world</span></div></body></html>"#, 1911 + r#"document.getElementById("d").textContent"#, 1912 + ) 1913 + .unwrap(); 1914 + match result { 1915 + Value::String(s) => assert_eq!(s, "hello world"), 1916 + v => panic!("expected 'hello world', got {v:?}"), 1917 + } 1918 + } 1919 + 1920 + #[test] 1921 + fn test_text_content_set() { 1922 + let result = eval_with_doc( 1923 + r#"<html><body><div id="d"><span>old</span></div></body></html>"#, 1924 + r#" 1925 + var d = document.getElementById("d"); 1926 + d.textContent = "new text"; 1927 + d.textContent 1928 + "#, 1929 + ) 1930 + .unwrap(); 1931 + match result { 1932 + Value::String(s) => assert_eq!(s, "new text"), 1933 + v => panic!("expected 'new text', got {v:?}"), 1934 + } 1935 + } 1936 + 1937 + // ── innerHTML tests ────────────────────────────────── 1938 + 1939 + #[test] 1940 + fn test_inner_html_get() { 1941 + let result = eval_with_doc( 1942 + r#"<html><body><div id="d"><span>hi</span></div></body></html>"#, 1943 + r#"document.getElementById("d").innerHTML"#, 1944 + ) 1945 + .unwrap(); 1946 + match result { 1947 + Value::String(s) => assert_eq!(s, "<span>hi</span>"), 1948 + v => panic!("expected '<span>hi</span>', got {v:?}"), 1949 + } 1950 + } 1951 + 1952 + #[test] 1953 + fn test_inner_html_set() { 1954 + let result = eval_with_doc( 1955 + r#"<html><body><div id="d"></div></body></html>"#, 1956 + r#" 1957 + var d = document.getElementById("d"); 1958 + d.innerHTML = "<em>new</em>"; 1959 + d.firstChild.tagName 1960 + "#, 1961 + ) 1962 + .unwrap(); 1963 + match result { 1964 + Value::String(s) => assert_eq!(s, "EM"), 1965 + v => panic!("expected 'EM', got {v:?}"), 1966 + } 1967 + } 1968 + 1969 + // ── outerHTML test ─────────────────────────────────── 1970 + 1971 + #[test] 1972 + fn test_outer_html() { 1973 + let result = eval_with_doc( 1974 + r#"<html><body><div id="d"><span>hi</span></div></body></html>"#, 1975 + r#"document.getElementById("d").outerHTML"#, 1976 + ) 1977 + .unwrap(); 1978 + match result { 1979 + Value::String(s) => assert!( 1980 + s.contains("<div") && s.contains("</div>") && s.contains("<span>hi</span>"), 1981 + "unexpected outerHTML: {s}" 1982 + ), 1983 + v => panic!("expected string, got {v:?}"), 1984 + } 1985 + } 1986 + 1987 + // ── getAttribute / setAttribute tests ──────────────── 1988 + 1989 + #[test] 1990 + fn test_get_attribute() { 1991 + let result = eval_with_doc( 1992 + r#"<html><body><a id="link" href="https://example.com">x</a></body></html>"#, 1993 + r#"document.getElementById("link").getAttribute("href")"#, 1994 + ) 1995 + .unwrap(); 1996 + match result { 1997 + Value::String(s) => assert_eq!(s, "https://example.com"), 1998 + v => panic!("expected URL, got {v:?}"), 1999 + } 2000 + } 2001 + 2002 + #[test] 2003 + fn test_get_attribute_missing() { 2004 + let result = eval_with_doc( 2005 + r#"<html><body><div id="d"></div></body></html>"#, 2006 + r#"document.getElementById("d").getAttribute("nope") === null"#, 2007 + ) 2008 + .unwrap(); 2009 + assert!(matches!(result, Value::Boolean(true))); 2010 + } 2011 + 2012 + #[test] 2013 + fn test_set_attribute() { 2014 + let result = eval_with_doc( 2015 + r#"<html><body><div id="d"></div></body></html>"#, 2016 + r#" 2017 + var d = document.getElementById("d"); 2018 + d.setAttribute("data-x", "123"); 2019 + d.getAttribute("data-x") 2020 + "#, 2021 + ) 2022 + .unwrap(); 2023 + match result { 2024 + Value::String(s) => assert_eq!(s, "123"), 2025 + v => panic!("expected '123', got {v:?}"), 2026 + } 2027 + } 2028 + 2029 + #[test] 2030 + fn test_has_attribute() { 2031 + let result = eval_with_doc( 2032 + r#"<html><body><div id="d" class="foo"></div></body></html>"#, 2033 + r#" 2034 + var d = document.getElementById("d"); 2035 + d.hasAttribute("class") && !d.hasAttribute("nope") 2036 + "#, 2037 + ) 2038 + .unwrap(); 2039 + assert!(matches!(result, Value::Boolean(true))); 2040 + } 2041 + 2042 + #[test] 2043 + fn test_remove_attribute() { 2044 + let result = eval_with_doc( 2045 + r#"<html><body><div id="d" class="foo"></div></body></html>"#, 2046 + r#" 2047 + var d = document.getElementById("d"); 2048 + d.removeAttribute("class"); 2049 + d.hasAttribute("class") 2050 + "#, 2051 + ) 2052 + .unwrap(); 2053 + assert!(matches!(result, Value::Boolean(false))); 2054 + } 2055 + 2056 + // ── Navigation property tests ──────────────────────── 2057 + 2058 + #[test] 2059 + fn test_parent_node() { 2060 + let result = eval_with_doc( 2061 + r#"<html><body><div id="child"></div></body></html>"#, 2062 + r#"document.getElementById("child").parentNode.tagName"#, 2063 + ) 2064 + .unwrap(); 2065 + match result { 2066 + Value::String(s) => assert_eq!(s, "BODY"), 2067 + v => panic!("expected 'BODY', got {v:?}"), 2068 + } 2069 + } 2070 + 2071 + #[test] 2072 + fn test_child_nodes() { 2073 + let result = eval_with_doc( 2074 + r#"<html><body><div id="p"><span>a</span><span>b</span></div></body></html>"#, 2075 + r#"document.getElementById("p").childNodes.length"#, 2076 + ) 2077 + .unwrap(); 2078 + match result { 2079 + Value::Number(n) => assert_eq!(n, 2.0), 2080 + v => panic!("expected 2, got {v:?}"), 2081 + } 2082 + } 2083 + 2084 + #[test] 2085 + fn test_children_element_only() { 2086 + let result = eval_with_doc( 2087 + r#"<html><body><div id="p"><span>a</span>text<span>b</span></div></body></html>"#, 2088 + r#"document.getElementById("p").children.length"#, 2089 + ) 2090 + .unwrap(); 2091 + match result { 2092 + Value::Number(n) => assert_eq!(n, 2.0), 2093 + v => panic!("expected 2, got {v:?}"), 2094 + } 2095 + } 2096 + 2097 + #[test] 2098 + fn test_first_child() { 2099 + let result = eval_with_doc( 2100 + r#"<html><body><div id="p"><span>a</span><em>b</em></div></body></html>"#, 2101 + r#"document.getElementById("p").firstChild.tagName"#, 2102 + ) 2103 + .unwrap(); 2104 + match result { 2105 + Value::String(s) => assert_eq!(s, "SPAN"), 2106 + v => panic!("expected 'SPAN', got {v:?}"), 2107 + } 2108 + } 2109 + 2110 + #[test] 2111 + fn test_last_child() { 2112 + let result = eval_with_doc( 2113 + r#"<html><body><div id="p"><span>a</span><em>b</em></div></body></html>"#, 2114 + r#"document.getElementById("p").lastChild.tagName"#, 2115 + ) 2116 + .unwrap(); 2117 + match result { 2118 + Value::String(s) => assert_eq!(s, "EM"), 2119 + v => panic!("expected 'EM', got {v:?}"), 2120 + } 2121 + } 2122 + 2123 + #[test] 2124 + fn test_next_sibling() { 2125 + let result = eval_with_doc( 2126 + r#"<html><body><span id="a">1</span><em id="b">2</em></body></html>"#, 2127 + r#"document.getElementById("a").nextSibling.tagName"#, 2128 + ) 2129 + .unwrap(); 2130 + match result { 2131 + Value::String(s) => assert_eq!(s, "EM"), 2132 + v => panic!("expected 'EM', got {v:?}"), 2133 + } 2134 + } 2135 + 2136 + #[test] 2137 + fn test_previous_sibling() { 2138 + let result = eval_with_doc( 2139 + r#"<html><body><span id="a">1</span><em id="b">2</em></body></html>"#, 2140 + r#"document.getElementById("b").previousSibling.tagName"#, 2141 + ) 2142 + .unwrap(); 2143 + match result { 2144 + Value::String(s) => assert_eq!(s, "SPAN"), 2145 + v => panic!("expected 'SPAN', got {v:?}"), 2146 + } 2147 + } 2148 + 2149 + #[test] 2150 + fn test_null_navigation() { 2151 + let result = eval_with_doc( 2152 + r#"<html><body><div id="only"></div></body></html>"#, 2153 + r#"document.getElementById("only").nextSibling === null"#, 2154 + ) 2155 + .unwrap(); 2156 + assert!(matches!(result, Value::Boolean(true))); 2157 + } 2158 + 2159 + // ── style property tests ───────────────────────────── 2160 + 2161 + #[test] 2162 + fn test_style_set_and_get() { 2163 + let result = eval_with_doc( 2164 + r#"<html><body><div id="d"></div></body></html>"#, 2165 + r#" 2166 + var d = document.getElementById("d"); 2167 + d.style.color = "red"; 2168 + d.style.color 2169 + "#, 2170 + ) 2171 + .unwrap(); 2172 + match result { 2173 + Value::String(s) => assert_eq!(s, "red"), 2174 + v => panic!("expected 'red', got {v:?}"), 2175 + } 2176 + } 2177 + 2178 + #[test] 2179 + fn test_style_read_existing() { 2180 + let result = eval_with_doc( 2181 + r#"<html><body><div id="d" style="color: blue"></div></body></html>"#, 2182 + r#"document.getElementById("d").style.color"#, 2183 + ) 2184 + .unwrap(); 2185 + match result { 2186 + Value::String(s) => assert_eq!(s, "blue"), 2187 + v => panic!("expected 'blue', got {v:?}"), 2188 + } 2189 + } 2190 + 2191 + // ── classList tests ────────────────────────────────── 2192 + 2193 + #[test] 2194 + fn test_class_list_add() { 2195 + let result = eval_with_doc( 2196 + r#"<html><body><div id="d"></div></body></html>"#, 2197 + r#" 2198 + var d = document.getElementById("d"); 2199 + d.classList.add("foo"); 2200 + d.getAttribute("class") 2201 + "#, 2202 + ) 2203 + .unwrap(); 2204 + match result { 2205 + Value::String(s) => assert_eq!(s, "foo"), 2206 + v => panic!("expected 'foo', got {v:?}"), 2207 + } 2208 + } 2209 + 2210 + #[test] 2211 + fn test_class_list_remove() { 2212 + let result = eval_with_doc( 2213 + r#"<html><body><div id="d" class="foo bar"></div></body></html>"#, 2214 + r#" 2215 + var d = document.getElementById("d"); 2216 + d.classList.remove("foo"); 2217 + d.getAttribute("class") 2218 + "#, 2219 + ) 2220 + .unwrap(); 2221 + match result { 2222 + Value::String(s) => assert_eq!(s, "bar"), 2223 + v => panic!("expected 'bar', got {v:?}"), 2224 + } 2225 + } 2226 + 2227 + #[test] 2228 + fn test_class_list_toggle() { 2229 + let result = eval_with_doc( 2230 + r#"<html><body><div id="d" class="foo"></div></body></html>"#, 2231 + r#" 2232 + var d = document.getElementById("d"); 2233 + var removed = d.classList.toggle("foo"); 2234 + var added = d.classList.toggle("bar"); 2235 + !removed && added 2236 + "#, 2237 + ) 2238 + .unwrap(); 2239 + assert!(matches!(result, Value::Boolean(true))); 2240 + } 2241 + 2242 + #[test] 2243 + fn test_class_list_contains() { 2244 + let result = eval_with_doc( 2245 + r#"<html><body><div id="d" class="foo bar"></div></body></html>"#, 2246 + r#" 2247 + var d = document.getElementById("d"); 2248 + d.classList.contains("foo") && !d.classList.contains("baz") 2249 + "#, 2250 + ) 2251 + .unwrap(); 2252 + assert!(matches!(result, Value::Boolean(true))); 2253 + } 2254 + 2255 + // ── setAttribute syncs id/className ────────────────── 2256 + 2257 + #[test] 2258 + fn test_set_attribute_syncs_id() { 2259 + let result = eval_with_doc( 2260 + r#"<html><body><div id="d"></div></body></html>"#, 2261 + r#" 2262 + var d = document.getElementById("d"); 2263 + d.setAttribute("id", "new_id"); 2264 + d.id 2265 + "#, 2266 + ) 2267 + .unwrap(); 2268 + match result { 2269 + Value::String(s) => assert_eq!(s, "new_id"), 2270 + v => panic!("expected 'new_id', got {v:?}"), 2271 + } 2272 + } 2273 + 2274 + // ── id and className setters ───────────────────────── 2275 + 2276 + #[test] 2277 + fn test_set_id_syncs_attribute() { 2278 + let result = eval_with_doc( 2279 + r#"<html><body><div id="d"></div></body></html>"#, 2280 + r#" 2281 + var d = document.getElementById("d"); 2282 + d.id = "new_id"; 2283 + d.getAttribute("id") 2284 + "#, 2285 + ) 2286 + .unwrap(); 2287 + match result { 2288 + Value::String(s) => assert_eq!(s, "new_id"), 2289 + v => panic!("expected 'new_id', got {v:?}"), 2290 + } 2291 + } 2292 + 2293 + #[test] 2294 + fn test_set_class_name_syncs_attribute() { 2295 + let result = eval_with_doc( 2296 + r#"<html><body><div id="d"></div></body></html>"#, 2297 + r#" 2298 + var d = document.getElementById("d"); 2299 + d.className = "a b"; 2300 + d.getAttribute("class") 2301 + "#, 2302 + ) 2303 + .unwrap(); 2304 + match result { 2305 + Value::String(s) => assert_eq!(s, "a b"), 2306 + v => panic!("expected 'a b', got {v:?}"), 2307 + } 2308 + } 2309 + 2310 + // ── Navigation after DOM modification ──────────────── 2311 + 2312 + #[test] 2313 + fn test_navigation_after_append() { 2314 + let result = eval_with_doc( 2315 + r#"<html><body><div id="p"></div></body></html>"#, 2316 + r#" 2317 + var p = document.getElementById("p"); 2318 + var a = document.createElement("span"); 2319 + var b = document.createElement("em"); 2320 + p.appendChild(a); 2321 + p.appendChild(b); 2322 + p.firstChild.tagName + "," + p.lastChild.tagName 2323 + "#, 2324 + ) 2325 + .unwrap(); 2326 + match result { 2327 + Value::String(s) => assert_eq!(s, "SPAN,EM"), 2328 + v => panic!("expected 'SPAN,EM', got {v:?}"), 2329 + } 2330 + } 2331 + 2332 + // ── Removed nodes are detached ─────────────────────── 2333 + 2334 + #[test] 2335 + fn test_removed_node_detached() { 2336 + let result = eval_with_doc( 2337 + r#"<html><body><div id="p"><span id="c"></span></div></body></html>"#, 2338 + r#" 2339 + var p = document.getElementById("p"); 2340 + var c = document.getElementById("c"); 2341 + p.removeChild(c); 2342 + c.parentNode === null 2343 + "#, 2344 + ) 2345 + .unwrap(); 2346 + assert!(matches!(result, Value::Boolean(true))); 764 2347 } 765 2348 }
+98 -36
crates/js/src/vm.rs
··· 1959 1959 } 1960 1960 1961 1961 /// Collect all GcRef values reachable from the mutator (roots for GC). 1962 + /// Resolve a dynamic DOM property for a wrapper object. 1963 + /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. 1964 + fn resolve_dom_property(&mut self, gc_ref: GcRef, key: &str) -> Option<Value> { 1965 + let bridge = Rc::clone(self.dom_bridge.as_ref()?); 1966 + crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) 1967 + } 1968 + 1969 + /// Handle a DOM property set on a wrapper object. 1970 + /// Returns `true` if the property was handled (caller should skip normal set). 1971 + fn handle_dom_property_set(&mut self, gc_ref: GcRef, key: &str, val: &Value) -> bool { 1972 + if let Some(bridge) = self.dom_bridge.clone() { 1973 + // Check for style proxy objects first. 1974 + if crate::dom_bridge::handle_style_set(&mut self.gc, &bridge, gc_ref, key, val) { 1975 + return true; 1976 + } 1977 + // Then check for DOM node wrapper sets. 1978 + crate::dom_bridge::handle_dom_set(&mut self.gc, &bridge, gc_ref, key, val) 1979 + } else { 1980 + false 1981 + } 1982 + } 1983 + 1962 1984 fn collect_roots(&self) -> Vec<GcRef> { 1963 1985 let mut roots = Vec::new(); 1964 1986 for val in &self.registers { ··· 2804 2826 let key_r = Self::read_u8(&mut self.frames[fi]); 2805 2827 let base = self.frames[fi].base; 2806 2828 let key = self.registers[base + key_r as usize].to_js_string(&self.gc); 2829 + // Save gc_ref for DOM interception. 2830 + let obj_gc_ref = match self.registers[base + obj_r as usize] { 2831 + Value::Object(r) | Value::Function(r) => Some(r), 2832 + _ => None, 2833 + }; 2807 2834 let val = match self.registers[base + obj_r as usize] { 2808 2835 Value::Object(gc_ref) | Value::Function(gc_ref) => { 2809 2836 gc_get_property(&self.gc, gc_ref, &key) ··· 2828 2855 .unwrap_or(Value::Undefined), 2829 2856 _ => Value::Undefined, 2830 2857 }; 2858 + // DOM dynamic property interception. 2859 + let val = match val { 2860 + Value::Undefined if obj_gc_ref.is_some() => self 2861 + .resolve_dom_property(obj_gc_ref.unwrap(), &key) 2862 + .unwrap_or(Value::Undefined), 2863 + other => other, 2864 + }; 2831 2865 self.registers[base + dst as usize] = val; 2832 2866 } 2833 2867 Op::SetProperty => { ··· 2837 2871 let base = self.frames[fi].base; 2838 2872 let key = self.registers[base + key_r as usize].to_js_string(&self.gc); 2839 2873 let val = self.registers[base + val_r as usize].clone(); 2840 - match self.registers[base + obj_r as usize] { 2841 - Value::Object(gc_ref) => { 2842 - if let Some(HeapObject::Object(data)) = self.gc.get_mut(gc_ref) { 2843 - if let Some(prop) = data.properties.get_mut(&key) { 2844 - if prop.writable { 2845 - prop.value = val; 2874 + let obj_gc = match self.registers[base + obj_r as usize] { 2875 + Value::Object(r) => Some(r), 2876 + Value::Function(r) => Some(r), 2877 + _ => None, 2878 + }; 2879 + // DOM property set interception. 2880 + let dom_handled = obj_gc 2881 + .map(|r| self.handle_dom_property_set(r, &key, &val)) 2882 + .unwrap_or(false); 2883 + if !dom_handled { 2884 + if let Some(gc_ref) = obj_gc { 2885 + match self.gc.get_mut(gc_ref) { 2886 + Some(HeapObject::Object(data)) => { 2887 + if let Some(prop) = data.properties.get_mut(&key) { 2888 + if prop.writable { 2889 + prop.value = val; 2890 + } 2891 + } else { 2892 + data.properties.insert(key, Property::data(val)); 2846 2893 } 2847 - } else { 2848 - data.properties.insert(key, Property::data(val)); 2849 2894 } 2850 - } 2851 - } 2852 - Value::Function(gc_ref) => { 2853 - if let Some(HeapObject::Function(fdata)) = self.gc.get_mut(gc_ref) { 2854 - if let Some(prop) = fdata.properties.get_mut(&key) { 2855 - if prop.writable { 2856 - prop.value = val; 2895 + Some(HeapObject::Function(fdata)) => { 2896 + if let Some(prop) = fdata.properties.get_mut(&key) { 2897 + if prop.writable { 2898 + prop.value = val; 2899 + } 2900 + } else { 2901 + fdata.properties.insert(key, Property::data(val)); 2857 2902 } 2858 - } else { 2859 - fdata.properties.insert(key, Property::data(val)); 2860 2903 } 2904 + _ => {} 2861 2905 } 2862 2906 } 2863 - _ => {} 2864 2907 } 2865 2908 } 2866 2909 Op::CreateObject => { ··· 2904 2947 let name_idx = Self::read_u16(&mut self.frames[fi]) as usize; 2905 2948 let base = self.frames[fi].base; 2906 2949 let key = self.frames[fi].func.names[name_idx].clone(); 2950 + let obj_gc_ref = match self.registers[base + obj_r as usize] { 2951 + Value::Object(r) | Value::Function(r) => Some(r), 2952 + _ => None, 2953 + }; 2907 2954 let val = match self.registers[base + obj_r as usize] { 2908 2955 Value::Object(gc_ref) | Value::Function(gc_ref) => { 2909 2956 gc_get_property(&self.gc, gc_ref, &key) ··· 2928 2975 .unwrap_or(Value::Undefined), 2929 2976 _ => Value::Undefined, 2930 2977 }; 2978 + // DOM dynamic property interception. 2979 + let val = match val { 2980 + Value::Undefined if obj_gc_ref.is_some() => self 2981 + .resolve_dom_property(obj_gc_ref.unwrap(), &key) 2982 + .unwrap_or(Value::Undefined), 2983 + other => other, 2984 + }; 2931 2985 self.registers[base + dst as usize] = val; 2932 2986 } 2933 2987 Op::SetPropertyByName => { ··· 2937 2991 let base = self.frames[fi].base; 2938 2992 let key = self.frames[fi].func.names[name_idx].clone(); 2939 2993 let val = self.registers[base + val_r as usize].clone(); 2940 - match self.registers[base + obj_r as usize] { 2941 - Value::Object(gc_ref) => { 2942 - if let Some(HeapObject::Object(data)) = self.gc.get_mut(gc_ref) { 2943 - if let Some(prop) = data.properties.get_mut(&key) { 2944 - if prop.writable { 2945 - prop.value = val; 2994 + let obj_gc = match self.registers[base + obj_r as usize] { 2995 + Value::Object(r) => Some(r), 2996 + Value::Function(r) => Some(r), 2997 + _ => None, 2998 + }; 2999 + let dom_handled = obj_gc 3000 + .map(|r| self.handle_dom_property_set(r, &key, &val)) 3001 + .unwrap_or(false); 3002 + if !dom_handled { 3003 + if let Some(gc_ref) = obj_gc { 3004 + match self.gc.get_mut(gc_ref) { 3005 + Some(HeapObject::Object(data)) => { 3006 + if let Some(prop) = data.properties.get_mut(&key) { 3007 + if prop.writable { 3008 + prop.value = val; 3009 + } 3010 + } else { 3011 + data.properties.insert(key, Property::data(val)); 2946 3012 } 2947 - } else { 2948 - data.properties.insert(key, Property::data(val)); 2949 3013 } 2950 - } 2951 - } 2952 - Value::Function(gc_ref) => { 2953 - if let Some(HeapObject::Function(fdata)) = self.gc.get_mut(gc_ref) { 2954 - if let Some(prop) = fdata.properties.get_mut(&key) { 2955 - if prop.writable { 2956 - prop.value = val; 3014 + Some(HeapObject::Function(fdata)) => { 3015 + if let Some(prop) = fdata.properties.get_mut(&key) { 3016 + if prop.writable { 3017 + prop.value = val; 3018 + } 3019 + } else { 3020 + fdata.properties.insert(key, Property::data(val)); 2957 3021 } 2958 - } else { 2959 - fdata.properties.insert(key, Property::data(val)); 2960 3022 } 3023 + _ => {} 2961 3024 } 2962 3025 } 2963 - _ => {} 2964 3026 } 2965 3027 } 2966 3028