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 page lifecycle events (Phase 17)

Add DOMContentLoaded, load, beforeunload, pagehide, and unload events
with correct firing order. Implement document.readyState property
with loading → interactive → complete transitions.

- DOMContentLoaded fires on document after sync scripts, bubbles to window
- load fires on window after all resources (CSS, fonts, images) are loaded
- beforeunload fires before navigation and can cancel via preventDefault()
- pagehide and unload fire during document teardown
- VM persisted in PageState so lifecycle handlers survive page load
- Window object gets sentinel __node_id__ for addEventListener support
- 10 new unit tests covering event ordering, readyState transitions,
and beforeunload cancellation

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

+843 -23
+131 -19
crates/browser/src/main.rs
··· 11 11 use we_browser::img_loader::{collect_images, ImageStore}; 12 12 use we_browser::loader::{LoadError, Resource, ResourceLoader, ABOUT_BLANK_HTML}; 13 13 use we_browser::navigation_history::NavigationHistory; 14 - use we_browser::script_loader::execute_page_scripts; 14 + use we_browser::script_loader::execute_scripts_into_vm; 15 15 use we_css::parser::Stylesheet; 16 16 use we_css::values::Color; 17 17 use we_dom::{Document, NodeData, NodeId}; ··· 64 64 font_registry: FontRegistry, 65 65 /// Base URL of the current page (for resolving relative URLs in links). 66 66 base_url: Url, 67 + /// JS VM kept alive for the lifetime of the page so that event 68 + /// listeners (including lifecycle handlers like `beforeunload`) 69 + /// remain callable. 70 + vm: Option<we_js::vm::Vm>, 67 71 } 68 72 69 73 // --------------------------------------------------------------------------- ··· 1152 1156 }; 1153 1157 1154 1158 // 7. Navigate: replace current page with the response. 1159 + // Fire beforeunload/pagehide/unload on the current page. 1160 + if !fire_unload_events(state) { 1161 + eprintln!("[we] Form submission cancelled by beforeunload handler"); 1162 + return; 1163 + } 1164 + 1155 1165 // Save scroll position before navigating away. 1156 1166 state.history.save_scroll(0.0, state.page_scroll_y); 1157 1167 ··· 2727 2737 eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); 2728 2738 } 2729 2739 2740 + /// Fire `beforeunload`, `pagehide`, and `unload` events on the current page. 2741 + /// Returns `false` if `beforeunload` was cancelled (navigation should abort). 2742 + /// 2743 + /// The document is temporarily re-attached to the stored VM so that event 2744 + /// listeners registered during page load can fire. 2745 + fn fire_unload_events(state: &mut BrowserState) -> bool { 2746 + let vm = match state.page.vm.as_mut() { 2747 + Some(vm) => vm, 2748 + None => return true, // No VM → no listeners → proceed. 2749 + }; 2750 + 2751 + // Temporarily take the document out of PageState and attach to VM. 2752 + let doc = std::mem::replace(&mut state.page.doc, we_dom::Document::new()); 2753 + vm.attach_document(doc); 2754 + 2755 + // Re-initialize the event system so that the document wrapper is set up 2756 + // with the correct __node_id__ for dispatch. 2757 + we_js::dom_bridge::init_event_system(vm); 2758 + 2759 + // 1. Fire beforeunload on window (cancelable). 2760 + let proceed = we_js::dom_bridge::fire_lifecycle_event( 2761 + vm, 2762 + "beforeunload", 2763 + we_js::dom_bridge::LifecycleTarget::Window, 2764 + false, // does NOT bubble 2765 + true, // cancelable 2766 + ); 2767 + 2768 + if !proceed { 2769 + // Navigation cancelled — put the document back. 2770 + let doc = vm.detach_document().unwrap_or_default(); 2771 + state.page.doc = doc; 2772 + return false; 2773 + } 2774 + 2775 + // 2. Fire pagehide on window. 2776 + we_js::dom_bridge::fire_lifecycle_event( 2777 + vm, 2778 + "pagehide", 2779 + we_js::dom_bridge::LifecycleTarget::Window, 2780 + false, // does NOT bubble 2781 + false, // not cancelable 2782 + ); 2783 + 2784 + // 3. Fire unload on window. 2785 + we_js::dom_bridge::fire_lifecycle_event( 2786 + vm, 2787 + "unload", 2788 + we_js::dom_bridge::LifecycleTarget::Window, 2789 + false, // does NOT bubble 2790 + false, // not cancelable 2791 + ); 2792 + 2793 + // Detach the document (it will be replaced by the new page). 2794 + let doc = vm.detach_document().unwrap_or_default(); 2795 + state.page.doc = doc; 2796 + true 2797 + } 2798 + 2730 2799 /// Navigate to a link target. Resolves the href against the page base URL, 2731 2800 /// loads the new page, and replaces the current browsing context. 2732 2801 fn navigate_to_link(state: &mut BrowserState, href: &str) { ··· 2772 2841 2773 2842 eprintln!("[we] Navigating to: {}", target_url.serialize()); 2774 2843 2844 + // Fire beforeunload/pagehide/unload on the current page. 2845 + if !fire_unload_events(state) { 2846 + eprintln!("[we] Navigation cancelled by beforeunload handler"); 2847 + return; 2848 + } 2849 + 2775 2850 // Save the current scroll position before navigating away. 2776 2851 state.history.save_scroll(0.0, state.page_scroll_y); 2777 2852 ··· 2829 2904 state.chrome.set_url(&state.page.base_url.serialize()); 2830 2905 rerender(state); 2831 2906 eprintln!("[we] hashchange: oldURL={old_url_str}, newURL={new_url_str}"); 2907 + return; 2908 + } 2909 + 2910 + // Fire beforeunload/pagehide/unload on the current page. 2911 + if !fire_unload_events(state) { 2912 + eprintln!("[we] History navigation cancelled by beforeunload handler"); 2832 2913 return; 2833 2914 } 2834 2915 ··· 3414 3495 } 3415 3496 3416 3497 /// Load a page: fetch HTML, parse DOM, execute scripts, collect CSS, load web fonts, and images. 3498 + /// Fires lifecycle events (DOMContentLoaded, load) at the correct points. 3417 3499 fn load_page(loaded: LoadedHtml) -> PageState { 3418 3500 let doc = parse_html(&loaded.text); 3419 3501 ··· 3429 3511 3430 3512 // Execute <script> elements. Scripts may modify the DOM, so this must 3431 3513 // run before collecting CSS and images (which depend on DOM structure). 3514 + // The VM is kept alive so we can fire `load` after resources are loaded, 3515 + // and `beforeunload`/`unload` during future navigation. 3432 3516 let mut loader = ResourceLoader::new(); 3433 3517 loader.set_document_url(&loaded.base_url); 3434 3518 loader.set_csp(csp_policies); ··· 3437 3521 } else if let Some(policy) = loaded.http_referrer_policy { 3438 3522 loader.set_referrer_policy(policy); 3439 3523 } 3440 - let doc = execute_page_scripts(doc, &mut loader, &loaded.base_url); 3524 + 3525 + let document_origin = loaded.base_url.origin(); 3526 + let mut vm = execute_scripts_into_vm(doc, &mut loader, &loaded.base_url, &document_origin); 3527 + 3528 + // Borrow the (possibly script-modified) document to load subresources. 3529 + let (stylesheet, font_registry, images) = { 3530 + let doc_ref = vm.borrow_document().expect("document still attached"); 3441 3531 3442 - // Fetch external stylesheets and merge with inline <style> elements. 3443 - let stylesheet = collect_stylesheets(&doc, &mut loader, &loaded.base_url); 3532 + // Fetch external stylesheets and merge with inline <style> elements. 3533 + let stylesheet = collect_stylesheets(&doc_ref, &mut loader, &loaded.base_url); 3534 + 3535 + // Load web fonts from @font-face rules in the stylesheet. 3536 + let mut font_registry = FontRegistry::new(); 3537 + let font_result = load_web_fonts( 3538 + &stylesheet, 3539 + &mut loader, 3540 + &loaded.base_url, 3541 + &mut font_registry, 3542 + ); 3543 + if font_result.loaded > 0 { 3544 + eprintln!( 3545 + "[we] Loaded {} web font(s) ({} failed)", 3546 + font_result.loaded, font_result.failed 3547 + ); 3548 + } 3549 + 3550 + // Fetch and decode images referenced by <img> elements. 3551 + let images = collect_images(&doc_ref, &mut loader, &loaded.base_url); 3552 + 3553 + (stylesheet, font_registry, images) 3554 + }; 3444 3555 3445 - // Load web fonts from @font-face rules in the stylesheet. 3446 - let mut font_registry = FontRegistry::new(); 3447 - let font_result = load_web_fonts( 3448 - &stylesheet, 3449 - &mut loader, 3450 - &loaded.base_url, 3451 - &mut font_registry, 3556 + // All resources loaded: readyState → "complete", fire `load` on window. 3557 + vm.set_ready_state("complete"); 3558 + we_js::dom_bridge::fire_lifecycle_event( 3559 + &mut vm, 3560 + "load", 3561 + we_js::dom_bridge::LifecycleTarget::Window, 3562 + false, // does NOT bubble 3563 + false, // not cancelable 3452 3564 ); 3453 - if font_result.loaded > 0 { 3454 - eprintln!( 3455 - "[we] Loaded {} web font(s) ({} failed)", 3456 - font_result.loaded, font_result.failed 3457 - ); 3458 - } 3459 3565 3460 - // Fetch and decode images referenced by <img> elements. 3461 - let images = collect_images(&doc, &mut loader, &loaded.base_url); 3566 + // Pump the event loop one more time for any handlers that scheduled work. 3567 + let _ = vm.pump_event_loop(); 3568 + 3569 + // Detach the document from the VM. The VM is stored in PageState for 3570 + // later lifecycle events (beforeunload, unload). 3571 + we_js::fetch::clear_document_origin(); 3572 + let doc = vm.detach_document().unwrap_or_default(); 3462 3573 3463 3574 PageState { 3464 3575 doc, ··· 3466 3577 images, 3467 3578 font_registry, 3468 3579 base_url: loaded.base_url, 3580 + vm: Some(vm), 3469 3581 } 3470 3582 } 3471 3583
+141 -4
crates/browser/src/script_loader.rs
··· 196 196 base_url: &Url, 197 197 document_origin: &Origin, 198 198 ) -> Document { 199 + let mut vm = execute_scripts_into_vm(doc, loader, base_url, document_origin); 200 + we_js::fetch::clear_document_origin(); 201 + vm.detach_document().unwrap_or_default() 202 + } 203 + 204 + /// Execute all `<script>` elements and return the VM with the document still 205 + /// attached. This allows the caller to fire additional lifecycle events 206 + /// (e.g. `load`) after loading subresources. 207 + /// 208 + /// The caller MUST call `we_js::fetch::clear_document_origin()` and 209 + /// `vm.detach_document()` when done. 210 + pub fn execute_scripts_into_vm( 211 + doc: Document, 212 + loader: &mut ResourceLoader, 213 + base_url: &Url, 214 + document_origin: &Origin, 215 + ) -> Vm { 199 216 // Find all <script> elements in document order. 200 217 let mut script_nodes = Vec::new(); 201 218 let root = doc.root(); ··· 209 226 .collect(); 210 227 211 228 if scripts.is_empty() { 212 - return doc; 229 + // No scripts: still create a VM so lifecycle events can fire. 230 + let mut vm = Vm::new(); 231 + vm.attach_document(doc); 232 + let origin_str = document_origin.serialize(); 233 + we_js::fetch::set_document_origin(&origin_str); 234 + vm.set_document_origin(&origin_str); 235 + vm.set_document_url(base_url.clone()); 236 + we_js::iframe_bridge::init_window_object(&mut vm, "main", &origin_str); 237 + // readyState: parsing done → interactive. 238 + vm.set_ready_state("interactive"); 239 + // Fire DOMContentLoaded (no scripts to run, so fire immediately). 240 + we_js::dom_bridge::fire_lifecycle_event( 241 + &mut vm, 242 + "DOMContentLoaded", 243 + we_js::dom_bridge::LifecycleTarget::Document, 244 + true, // bubbles 245 + false, // not cancelable 246 + ); 247 + return vm; 213 248 } 214 249 215 250 // Create VM with DOM access. ··· 226 261 227 262 // Initialize the window global object (includes window.location). 228 263 we_js::iframe_bridge::init_window_object(&mut vm, "main", &origin_str); 264 + 265 + // readyState: HTML is already fully parsed → interactive. 266 + vm.set_ready_state("interactive"); 229 267 230 268 // Separate into immediate (sync + async) and deferred scripts. 231 269 let mut deferred_sources: Vec<(String, String)> = Vec::new(); ··· 264 302 } 265 303 } 266 304 305 + // Fire DOMContentLoaded after synchronous scripts, before deferred. 306 + we_js::dom_bridge::fire_lifecycle_event( 307 + &mut vm, 308 + "DOMContentLoaded", 309 + we_js::dom_bridge::LifecycleTarget::Document, 310 + true, // bubbles 311 + false, // not cancelable 312 + ); 313 + 267 314 // Execute deferred scripts in document order. 268 315 for (source, label) in &deferred_sources { 269 316 execute_script(&mut vm, source, label); ··· 272 319 // Pump the event loop to handle any pending microtasks/timers. 273 320 let _ = vm.pump_event_loop(); 274 321 275 - // Clear the document origin and take the document back from the VM. 276 - we_js::fetch::clear_document_origin(); 277 - vm.detach_document().unwrap_or_default() 322 + vm 278 323 } 279 324 280 325 #[cfg(test)] ··· 548 593 find_dynamic(&doc, doc.root()), 549 594 "script should have appended a <p> to #container" 550 595 ); 596 + } 597 + 598 + /// Helper: parse HTML, execute scripts with VM, return the VM. 599 + fn run_scripts_vm(html: &str) -> Vm { 600 + let doc = parse_html(html); 601 + let mut loader = ResourceLoader::new(); 602 + let base_url = Url::parse("about:blank").unwrap(); 603 + let origin = base_url.origin(); 604 + execute_scripts_into_vm(doc, &mut loader, &base_url, &origin) 605 + } 606 + 607 + #[test] 608 + fn test_dom_content_loaded_fires() { 609 + // DOMContentLoaded should fire during script execution. 610 + // A sync script registers a listener, then DOMContentLoaded fires 611 + // after sync scripts, recording the event. 612 + let html = r#"<html><body> 613 + <div id="result">none</div> 614 + <script> 615 + document.addEventListener("DOMContentLoaded", function() { 616 + document.getElementById("result").textContent = "fired"; 617 + }); 618 + </script> 619 + </body></html>"#; 620 + let mut vm = run_scripts_vm(html); 621 + let doc = vm.detach_document().unwrap(); 622 + 623 + fn find_result(doc: &Document, node: NodeId) -> Option<String> { 624 + if doc.tag_name(node) == Some("div") { 625 + if doc.get_attribute(node, "id") == Some("result") { 626 + for child in doc.children(node) { 627 + if let Some(text) = doc.text_content(child) { 628 + return Some(text.to_string()); 629 + } 630 + } 631 + } 632 + } 633 + for child in doc.children(node) { 634 + if let Some(result) = find_result(doc, child) { 635 + return Some(result); 636 + } 637 + } 638 + None 639 + } 640 + 641 + let result = find_result(&doc, doc.root()); 642 + assert_eq!(result.as_deref(), Some("fired")); 643 + } 644 + 645 + #[test] 646 + fn test_ready_state_interactive_during_scripts() { 647 + // readyState should be "interactive" when scripts run. 648 + let html = r#"<html><body> 649 + <div id="result">none</div> 650 + <script> 651 + document.getElementById("result").textContent = document.readyState; 652 + </script> 653 + </body></html>"#; 654 + let mut vm = run_scripts_vm(html); 655 + let doc = vm.detach_document().unwrap(); 656 + 657 + fn find_result(doc: &Document, node: NodeId) -> Option<String> { 658 + if doc.tag_name(node) == Some("div") { 659 + if doc.get_attribute(node, "id") == Some("result") { 660 + for child in doc.children(node) { 661 + if let Some(text) = doc.text_content(child) { 662 + return Some(text.to_string()); 663 + } 664 + } 665 + } 666 + } 667 + for child in doc.children(node) { 668 + if let Some(result) = find_result(doc, child) { 669 + return Some(result); 670 + } 671 + } 672 + None 673 + } 674 + 675 + let result = find_result(&doc, doc.root()); 676 + assert_eq!(result.as_deref(), Some("interactive")); 677 + } 678 + 679 + #[test] 680 + fn test_no_scripts_still_fires_dcl() { 681 + // Even pages with no scripts should get DOMContentLoaded fired. 682 + // The VM is created anyway for lifecycle support. 683 + let html = r#"<html><body><p>No scripts</p></body></html>"#; 684 + let mut vm = run_scripts_vm(html); 685 + // Verify the VM has a document and readyState is "interactive". 686 + assert_eq!(vm.ready_state(), "interactive"); 687 + let _ = vm.detach_document(); 551 688 } 552 689 }
+521
crates/js/src/dom_bridge.rs
··· 227 227 ); 228 228 } 229 229 230 + // Set document.readyState from the bridge. 231 + { 232 + let state = vm 233 + .dom_bridge 234 + .as_ref() 235 + .map(|b| b.ready_state.borrow().clone()) 236 + .unwrap_or_else(|| "loading".to_string()); 237 + data.insert_property( 238 + "readyState".to_string(), 239 + Property::builtin(Value::String(state)), 240 + &mut vm.shapes, 241 + ); 242 + } 243 + 230 244 let doc_ref = vm.gc.alloc(HeapObject::Object(data)); 231 245 232 246 // Set documentElement, head, body as wrapper properties. ··· 3454 3468 event_target_dispatch_event(args, ctx) 3455 3469 } 3456 3470 3471 + // ── Lifecycle event dispatch ──────────────────────────────────────── 3472 + 3473 + /// Target for a lifecycle event. 3474 + pub enum LifecycleTarget { 3475 + /// Fire on the document node (e.g. DOMContentLoaded). 3476 + Document, 3477 + /// Fire on the window pseudo-node (e.g. load, beforeunload, unload). 3478 + Window, 3479 + } 3480 + 3481 + /// Fire a lifecycle event directly from Rust, without going through JS 3482 + /// `dispatchEvent`. Creates an Event object, invokes listeners at the 3483 + /// target, and optionally bubbles from document to window. 3484 + /// 3485 + /// Returns `true` if `defaultPrevented` was NOT set (i.e. the default 3486 + /// action should proceed). Returns `false` if a handler cancelled it. 3487 + pub fn fire_lifecycle_event( 3488 + vm: &mut Vm, 3489 + event_type: &str, 3490 + target: LifecycleTarget, 3491 + bubbles: bool, 3492 + cancelable: bool, 3493 + ) -> bool { 3494 + let bridge = match vm.dom_bridge.clone() { 3495 + Some(b) => b, 3496 + None => return true, 3497 + }; 3498 + 3499 + let event_ref = 3500 + create_event_object(&mut vm.gc, &mut vm.shapes, event_type, bubbles, cancelable); 3501 + 3502 + // Determine the target index. 3503 + let target_idx = match target { 3504 + LifecycleTarget::Document => bridge.document.borrow().root().index(), 3505 + LifecycleTarget::Window => WINDOW_NODE_ID, 3506 + }; 3507 + 3508 + // Set event.target. 3509 + let target_wrapper = match target { 3510 + LifecycleTarget::Document => { 3511 + // Use the cached document wrapper if available. 3512 + bridge 3513 + .node_wrappers 3514 + .borrow() 3515 + .get(&target_idx) 3516 + .copied() 3517 + .or_else(|| { 3518 + vm.get_global("document").and_then(|v| match v { 3519 + Value::Object(r) => Some(*r), 3520 + _ => None, 3521 + }) 3522 + }) 3523 + } 3524 + LifecycleTarget::Window => vm.get_global("window").and_then(|v| match v { 3525 + Value::Object(r) => Some(*r), 3526 + _ => None, 3527 + }), 3528 + }; 3529 + 3530 + if let Some(wrapper) = target_wrapper { 3531 + set_builtin_prop( 3532 + &mut vm.gc, 3533 + &mut vm.shapes, 3534 + event_ref, 3535 + "target", 3536 + Value::Object(wrapper), 3537 + ); 3538 + } 3539 + 3540 + // Set event phase to AT_TARGET (2). 3541 + set_builtin_prop( 3542 + &mut vm.gc, 3543 + &mut vm.shapes, 3544 + event_ref, 3545 + "eventPhase", 3546 + Value::Number(2.0), 3547 + ); 3548 + set_builtin_prop( 3549 + &mut vm.gc, 3550 + &mut vm.shapes, 3551 + event_ref, 3552 + EVENT_PHASE_KEY, 3553 + Value::Number(2.0), 3554 + ); 3555 + 3556 + // Set currentTarget to target. 3557 + if let Some(wrapper) = target_wrapper { 3558 + set_builtin_prop( 3559 + &mut vm.gc, 3560 + &mut vm.shapes, 3561 + event_ref, 3562 + "currentTarget", 3563 + Value::Object(wrapper), 3564 + ); 3565 + } 3566 + 3567 + // Invoke listeners at the target. 3568 + invoke_listeners_by_idx(vm, &bridge, target_idx, event_ref, target_wrapper); 3569 + 3570 + // If the event bubbles and was fired on the document, also fire on 3571 + // window listeners (document → window bubbling). 3572 + if bubbles 3573 + && matches!(target, LifecycleTarget::Document) 3574 + && !is_propagation_stopped(&vm.gc, &vm.shapes, event_ref) 3575 + { 3576 + set_builtin_prop( 3577 + &mut vm.gc, 3578 + &mut vm.shapes, 3579 + event_ref, 3580 + "eventPhase", 3581 + Value::Number(3.0), 3582 + ); 3583 + set_builtin_prop( 3584 + &mut vm.gc, 3585 + &mut vm.shapes, 3586 + event_ref, 3587 + EVENT_PHASE_KEY, 3588 + Value::Number(3.0), 3589 + ); 3590 + 3591 + let window_wrapper = vm.get_global("window").and_then(|v| match v { 3592 + Value::Object(r) => Some(*r), 3593 + _ => None, 3594 + }); 3595 + if let Some(wrapper) = window_wrapper { 3596 + set_builtin_prop( 3597 + &mut vm.gc, 3598 + &mut vm.shapes, 3599 + event_ref, 3600 + "currentTarget", 3601 + Value::Object(wrapper), 3602 + ); 3603 + } 3604 + invoke_listeners_by_idx(vm, &bridge, WINDOW_NODE_ID, event_ref, window_wrapper); 3605 + } 3606 + 3607 + // Check defaultPrevented. 3608 + let default_prevented = match vm.gc.get(event_ref) { 3609 + Some(HeapObject::Object(data)) => data 3610 + .get_property(EVENT_DEFAULT_PREVENTED_KEY, &vm.shapes) 3611 + .map(|p| p.value.to_boolean()) 3612 + .unwrap_or(false), 3613 + _ => false, 3614 + }; 3615 + !default_prevented 3616 + } 3617 + 3618 + /// Invoke listeners by raw index (works for both DOM nodes and the window 3619 + /// sentinel). Similar to `invoke_listeners` but uses a raw `usize` index 3620 + /// instead of `NodeId`, and always fires all matching listeners (at-target 3621 + /// semantics). 3622 + fn invoke_listeners_by_idx( 3623 + vm: &mut Vm, 3624 + bridge: &DomBridge, 3625 + idx: usize, 3626 + event_ref: GcRef, 3627 + current_target: Option<GcRef>, 3628 + ) { 3629 + let event_type = match vm.gc.get(event_ref) { 3630 + Some(HeapObject::Object(data)) => match data.get_property(EVENT_TYPE_KEY, &vm.shapes) { 3631 + Some(Property { 3632 + value: Value::String(s), 3633 + .. 3634 + }) => s, 3635 + _ => return, 3636 + }, 3637 + _ => return, 3638 + }; 3639 + 3640 + let matching: Vec<(GcRef, bool)> = { 3641 + let listeners = bridge.event_listeners.borrow(); 3642 + match listeners.get(&idx) { 3643 + Some(list) => list 3644 + .iter() 3645 + .filter(|l| l.event_type == event_type) 3646 + .map(|l| (l.callback, l.once)) 3647 + .collect(), 3648 + None => return, 3649 + } 3650 + }; 3651 + 3652 + let ct = current_target.unwrap_or_else(|| { 3653 + // Fallback: create a dummy object if no wrapper available. 3654 + vm.gc.alloc(HeapObject::Object(ObjectData::new())) 3655 + }); 3656 + 3657 + for (callback_ref, once) in &matching { 3658 + if is_immediate_stopped(&vm.gc, &vm.shapes, event_ref) { 3659 + break; 3660 + } 3661 + let old_this = vm.get_global("this").cloned(); 3662 + vm.set_global("this", Value::Object(ct)); 3663 + 3664 + let _ = vm.call_function(*callback_ref, &[Value::Object(event_ref)]); 3665 + 3666 + match old_this { 3667 + Some(v) => vm.set_global("this", v), 3668 + None => vm.remove_global("this"), 3669 + } 3670 + 3671 + if *once { 3672 + let mut listeners = bridge.event_listeners.borrow_mut(); 3673 + if let Some(list) = listeners.get_mut(&idx) { 3674 + list.retain(|l| { 3675 + !(l.event_type == event_type && l.callback == *callback_ref && l.once) 3676 + }); 3677 + } 3678 + } 3679 + } 3680 + } 3681 + 3457 3682 // ── Tests ─────────────────────────────────────────────────────────── 3458 3683 3459 3684 #[cfg(test)] ··· 5363 5588 match result { 5364 5589 Value::Boolean(b) => assert!(!b), 5365 5590 v => panic!("expected false, got {v:?}"), 5591 + } 5592 + } 5593 + 5594 + // ── Lifecycle event tests ────────────────────────────────────── 5595 + 5596 + /// Helper: set up a VM with a document and window, return it ready for 5597 + /// lifecycle event testing. 5598 + fn setup_lifecycle_vm(html: &str) -> Vm { 5599 + let doc = doc_from_html(html); 5600 + let mut vm = Vm::new(); 5601 + vm.attach_document(doc); 5602 + vm.set_document_origin("https://example.com"); 5603 + crate::iframe_bridge::init_window_object(&mut vm, "main", "https://example.com"); 5604 + vm 5605 + } 5606 + 5607 + #[test] 5608 + fn test_ready_state_initial() { 5609 + let vm = setup_lifecycle_vm("<html><body></body></html>"); 5610 + assert_eq!(vm.ready_state(), "loading"); 5611 + } 5612 + 5613 + #[test] 5614 + fn test_ready_state_transitions() { 5615 + let mut vm = setup_lifecycle_vm("<html><body></body></html>"); 5616 + assert_eq!(vm.ready_state(), "loading"); 5617 + 5618 + vm.set_ready_state("interactive"); 5619 + assert_eq!(vm.ready_state(), "interactive"); 5620 + 5621 + // Verify the JS property is updated. 5622 + let js = r#"document.readyState"#; 5623 + let ast = Parser::parse(js).unwrap(); 5624 + let func = compiler::compile(&ast).unwrap(); 5625 + match vm.execute(&func).unwrap() { 5626 + Value::String(s) => assert_eq!(s, "interactive"), 5627 + v => panic!("expected String, got {v:?}"), 5628 + } 5629 + 5630 + vm.set_ready_state("complete"); 5631 + assert_eq!(vm.ready_state(), "complete"); 5632 + 5633 + match vm.execute(&func).unwrap() { 5634 + Value::String(s) => assert_eq!(s, "complete"), 5635 + v => panic!("expected String, got {v:?}"), 5636 + } 5637 + } 5638 + 5639 + #[test] 5640 + fn test_document_ready_state_property() { 5641 + let mut vm = setup_lifecycle_vm("<html><body></body></html>"); 5642 + let js = r#"document.readyState"#; 5643 + let ast = Parser::parse(js).unwrap(); 5644 + let func = compiler::compile(&ast).unwrap(); 5645 + match vm.execute(&func).unwrap() { 5646 + Value::String(s) => assert_eq!(s, "loading"), 5647 + v => panic!("expected String(\"loading\"), got {v:?}"), 5648 + } 5649 + } 5650 + 5651 + #[test] 5652 + fn test_fire_dom_content_loaded() { 5653 + let mut vm = setup_lifecycle_vm("<html><body><div id='log'></div></body></html>"); 5654 + 5655 + let js = r#" 5656 + var order = []; 5657 + document.addEventListener("DOMContentLoaded", function(e) { 5658 + order.push("dcl"); 5659 + }); 5660 + "#; 5661 + let ast = Parser::parse(js).unwrap(); 5662 + let func = compiler::compile(&ast).unwrap(); 5663 + vm.execute(&func).unwrap(); 5664 + 5665 + fire_lifecycle_event( 5666 + &mut vm, 5667 + "DOMContentLoaded", 5668 + LifecycleTarget::Document, 5669 + true, 5670 + false, 5671 + ); 5672 + 5673 + let check = r#"order.length"#; 5674 + let ast = Parser::parse(check).unwrap(); 5675 + let func = compiler::compile(&ast).unwrap(); 5676 + match vm.execute(&func).unwrap() { 5677 + Value::Number(n) => assert_eq!(n, 1.0), 5678 + v => panic!("expected Number(1), got {v:?}"), 5679 + } 5680 + } 5681 + 5682 + #[test] 5683 + fn test_dom_content_loaded_bubbles_to_window() { 5684 + let mut vm = setup_lifecycle_vm("<html><body></body></html>"); 5685 + 5686 + let js = r#" 5687 + var order = []; 5688 + document.addEventListener("DOMContentLoaded", function() { 5689 + order.push("doc"); 5690 + }); 5691 + window.addEventListener("DOMContentLoaded", function() { 5692 + order.push("win"); 5693 + }); 5694 + "#; 5695 + let ast = Parser::parse(js).unwrap(); 5696 + let func = compiler::compile(&ast).unwrap(); 5697 + vm.execute(&func).unwrap(); 5698 + 5699 + fire_lifecycle_event( 5700 + &mut vm, 5701 + "DOMContentLoaded", 5702 + LifecycleTarget::Document, 5703 + true, 5704 + false, 5705 + ); 5706 + 5707 + let check = r#"order[0] + "," + order[1]"#; 5708 + let ast = Parser::parse(check).unwrap(); 5709 + let func = compiler::compile(&ast).unwrap(); 5710 + match vm.execute(&func).unwrap() { 5711 + Value::String(s) => assert_eq!(s, "doc,win"), 5712 + v => panic!("expected \"doc,win\", got {v:?}"), 5713 + } 5714 + } 5715 + 5716 + #[test] 5717 + fn test_fire_load_on_window() { 5718 + let mut vm = setup_lifecycle_vm("<html><body><div id='t'>no</div></body></html>"); 5719 + 5720 + let js = r#" 5721 + window.addEventListener("load", function() { 5722 + document.getElementById("t").textContent = "yes"; 5723 + }); 5724 + "#; 5725 + let ast = Parser::parse(js).unwrap(); 5726 + let func = compiler::compile(&ast).unwrap(); 5727 + vm.execute(&func).unwrap(); 5728 + 5729 + fire_lifecycle_event(&mut vm, "load", LifecycleTarget::Window, false, false); 5730 + 5731 + // Verify the DOM was modified. 5732 + let check = r#"document.getElementById("t").textContent"#; 5733 + let ast = Parser::parse(check).unwrap(); 5734 + let func = compiler::compile(&ast).unwrap(); 5735 + match vm.execute(&func).unwrap() { 5736 + Value::String(s) => assert_eq!(s, "yes"), 5737 + v => panic!("expected \"yes\", got {v:?}"), 5738 + } 5739 + } 5740 + 5741 + #[test] 5742 + fn test_beforeunload_cancelable() { 5743 + let mut vm = setup_lifecycle_vm("<html><body></body></html>"); 5744 + 5745 + let js = r#" 5746 + window.addEventListener("beforeunload", function(e) { 5747 + e.preventDefault(); 5748 + }); 5749 + "#; 5750 + let ast = Parser::parse(js).unwrap(); 5751 + let func = compiler::compile(&ast).unwrap(); 5752 + vm.execute(&func).unwrap(); 5753 + 5754 + let proceed = fire_lifecycle_event( 5755 + &mut vm, 5756 + "beforeunload", 5757 + LifecycleTarget::Window, 5758 + false, 5759 + true, 5760 + ); 5761 + assert!(!proceed, "beforeunload should cancel navigation"); 5762 + } 5763 + 5764 + #[test] 5765 + fn test_beforeunload_not_cancelled() { 5766 + let mut vm = setup_lifecycle_vm("<html><body></body></html>"); 5767 + 5768 + let js = r#" 5769 + window.addEventListener("beforeunload", function(e) { 5770 + // No preventDefault — navigation should proceed. 5771 + }); 5772 + "#; 5773 + let ast = Parser::parse(js).unwrap(); 5774 + let func = compiler::compile(&ast).unwrap(); 5775 + vm.execute(&func).unwrap(); 5776 + 5777 + let proceed = fire_lifecycle_event( 5778 + &mut vm, 5779 + "beforeunload", 5780 + LifecycleTarget::Window, 5781 + false, 5782 + true, 5783 + ); 5784 + assert!(proceed, "navigation should proceed without preventDefault"); 5785 + } 5786 + 5787 + #[test] 5788 + fn test_lifecycle_event_ordering() { 5789 + let mut vm = setup_lifecycle_vm("<html><body><div id='log'>none</div></body></html>"); 5790 + 5791 + // Use DOM to track event ordering (avoids scope issues with call_function). 5792 + let js = r#" 5793 + document.addEventListener("DOMContentLoaded", function() { 5794 + var el = document.getElementById("log"); 5795 + el.textContent = el.textContent === "none" ? "dcl" : el.textContent + ",dcl"; 5796 + }); 5797 + window.addEventListener("load", function() { 5798 + var el = document.getElementById("log"); 5799 + el.textContent = el.textContent + ",load"; 5800 + }); 5801 + window.addEventListener("beforeunload", function() { 5802 + var el = document.getElementById("log"); 5803 + el.textContent = el.textContent + ",beforeunload"; 5804 + }); 5805 + window.addEventListener("pagehide", function() { 5806 + var el = document.getElementById("log"); 5807 + el.textContent = el.textContent + ",pagehide"; 5808 + }); 5809 + window.addEventListener("unload", function() { 5810 + var el = document.getElementById("log"); 5811 + el.textContent = el.textContent + ",unload"; 5812 + }); 5813 + "#; 5814 + let ast = Parser::parse(js).unwrap(); 5815 + let func = compiler::compile(&ast).unwrap(); 5816 + vm.execute(&func).unwrap(); 5817 + 5818 + // Simulate page load lifecycle. 5819 + vm.set_ready_state("interactive"); 5820 + fire_lifecycle_event( 5821 + &mut vm, 5822 + "DOMContentLoaded", 5823 + LifecycleTarget::Document, 5824 + true, 5825 + false, 5826 + ); 5827 + vm.set_ready_state("complete"); 5828 + fire_lifecycle_event(&mut vm, "load", LifecycleTarget::Window, false, false); 5829 + 5830 + // Simulate navigation away. 5831 + fire_lifecycle_event( 5832 + &mut vm, 5833 + "beforeunload", 5834 + LifecycleTarget::Window, 5835 + false, 5836 + true, 5837 + ); 5838 + fire_lifecycle_event(&mut vm, "pagehide", LifecycleTarget::Window, false, false); 5839 + fire_lifecycle_event(&mut vm, "unload", LifecycleTarget::Window, false, false); 5840 + 5841 + let check = r#"document.getElementById("log").textContent"#; 5842 + let ast = Parser::parse(check).unwrap(); 5843 + let func = compiler::compile(&ast).unwrap(); 5844 + match vm.execute(&func).unwrap() { 5845 + Value::String(s) => { 5846 + assert_eq!(s, "dcl,load,beforeunload,pagehide,unload"); 5847 + } 5848 + v => panic!("expected lifecycle ordering string, got {v:?}"), 5849 + } 5850 + } 5851 + 5852 + #[test] 5853 + fn test_ready_state_during_lifecycle() { 5854 + let mut vm = setup_lifecycle_vm("<html><body><div id='log'>none</div></body></html>"); 5855 + 5856 + let js = r#" 5857 + document.addEventListener("DOMContentLoaded", function() { 5858 + var el = document.getElementById("log"); 5859 + el.textContent = document.readyState; 5860 + }); 5861 + window.addEventListener("load", function() { 5862 + var el = document.getElementById("log"); 5863 + el.textContent = el.textContent + "," + document.readyState; 5864 + }); 5865 + "#; 5866 + let ast = Parser::parse(js).unwrap(); 5867 + let func = compiler::compile(&ast).unwrap(); 5868 + vm.execute(&func).unwrap(); 5869 + 5870 + vm.set_ready_state("interactive"); 5871 + fire_lifecycle_event( 5872 + &mut vm, 5873 + "DOMContentLoaded", 5874 + LifecycleTarget::Document, 5875 + true, 5876 + false, 5877 + ); 5878 + vm.set_ready_state("complete"); 5879 + fire_lifecycle_event(&mut vm, "load", LifecycleTarget::Window, false, false); 5880 + 5881 + let check = r#"document.getElementById("log").textContent"#; 5882 + let ast = Parser::parse(check).unwrap(); 5883 + let func = compiler::compile(&ast).unwrap(); 5884 + match vm.execute(&func).unwrap() { 5885 + Value::String(s) => assert_eq!(s, "interactive,complete"), 5886 + v => panic!("expected \"interactive,complete\", got {v:?}"), 5366 5887 } 5367 5888 } 5368 5889 }
+8
crates/js/src/iframe_bridge.rs
··· 72 72 &mut vm.shapes, 73 73 ); 74 74 75 + // Assign the window sentinel node ID so addEventListener/removeEventListener 76 + // can store event listeners for the window object in the shared map. 77 + data.insert_property( 78 + "__node_id__".to_string(), 79 + Property::builtin(Value::Number(WINDOW_NODE_ID as f64)), 80 + &mut vm.shapes, 81 + ); 82 + 75 83 // window.self, window.window refer to window itself (set after alloc). 76 84 let window_ref = vm.gc.alloc(HeapObject::Object(data)); 77 85
+1
crates/js/src/location.rs
··· 478 478 indexeddb: RefCell::new(crate::indexeddb::IndexedDbState::new()), 479 479 iframe_windows: RefCell::new(std::collections::HashMap::new()), 480 480 location_object: RefCell::new(None), 481 + ready_state: RefCell::new("loading".to_string()), 481 482 }) 482 483 } 483 484
+41
crates/js/src/vm.rs
··· 514 514 /// Bridge between JS and the DOM. Holds a shared document and a cache 515 515 /// mapping `NodeId` indices to their JS wrapper `GcRef` so that the same 516 516 /// DOM node always returns the same JS object (identity). 517 + /// Sentinel node ID for the `window` object in the event listener map. 518 + /// This allows `window.addEventListener` to store listeners alongside DOM 519 + /// node listeners using the same `HashMap<usize, Vec<EventListener>>`. 520 + /// Uses 2^53 - 1 (max safe integer in f64) to survive the Number round-trip. 521 + pub const WINDOW_NODE_ID: usize = (1_usize << 53) - 1; 522 + 517 523 pub struct DomBridge { 518 524 pub document: RefCell<Document>, 519 525 pub node_wrappers: RefCell<HashMap<usize, GcRef>>, ··· 537 543 pub iframe_windows: RefCell<HashMap<usize, GcRef>>, 538 544 /// The Location object (`window.location` / `document.location`). 539 545 pub location_object: RefCell<Option<GcRef>>, 546 + /// The document's readyState: "loading", "interactive", or "complete". 547 + pub ready_state: RefCell<String>, 540 548 } 541 549 542 550 /// Context passed to native functions, providing GC access and `this` binding. ··· 1214 1222 indexeddb: RefCell::new(crate::indexeddb::IndexedDbState::new()), 1215 1223 iframe_windows: RefCell::new(HashMap::new()), 1216 1224 location_object: RefCell::new(None), 1225 + ready_state: RefCell::new("loading".to_string()), 1217 1226 }); 1218 1227 self.dom_bridge = Some(bridge); 1219 1228 crate::dom_bridge::init_document_object(self); ··· 1231 1240 if let Some(bridge) = &self.dom_bridge { 1232 1241 *bridge.origin.borrow_mut() = origin.to_string(); 1233 1242 } 1243 + } 1244 + 1245 + /// Set the document's readyState and update the `document.readyState` 1246 + /// JS property to match. 1247 + pub fn set_ready_state(&mut self, state: &str) { 1248 + if let Some(bridge) = &self.dom_bridge { 1249 + *bridge.ready_state.borrow_mut() = state.to_string(); 1250 + } 1251 + // Also update the JS `document.readyState` property. 1252 + if let Some(Value::Object(doc_ref)) = self.get_global("document").cloned() { 1253 + crate::builtins::set_builtin_prop( 1254 + &mut self.gc, 1255 + &mut self.shapes, 1256 + doc_ref, 1257 + "readyState", 1258 + Value::String(state.to_string()), 1259 + ); 1260 + } 1261 + } 1262 + 1263 + /// Get the current document readyState. 1264 + pub fn ready_state(&self) -> String { 1265 + self.dom_bridge 1266 + .as_ref() 1267 + .map(|b| b.ready_state.borrow().clone()) 1268 + .unwrap_or_else(|| "loading".to_string()) 1234 1269 } 1235 1270 1236 1271 /// Set the document URL for cookie domain/path matching. ··· 1300 1335 .indexeddb 1301 1336 .replace(crate::indexeddb::IndexedDbState::new()) 1302 1337 }) 1338 + } 1339 + 1340 + /// Borrow the attached document immutably. Panics if no document is 1341 + /// attached or the borrow is already held mutably. 1342 + pub fn borrow_document(&self) -> Option<std::cell::Ref<'_, Document>> { 1343 + self.dom_bridge.as_ref().map(|b| b.document.borrow()) 1303 1344 } 1304 1345 1305 1346 /// Register an iframe's window proxy so that `contentWindow` can return it.