diff --git a/crates/browser/src/script_loader.rs b/crates/browser/src/script_loader.rs index 408b9eb..6e2dcd6 100644 --- a/crates/browser/src/script_loader.rs +++ b/crates/browser/src/script_loader.rs @@ -162,8 +162,7 @@ fn fetch_script_text( document_origin: &Origin, element_policy: Option, ) -> Option { - let url = match we_url::Url::parse_with_base(src, base_url).or_else(|_| we_url::Url::parse(src)) - { + let url = match resolve_script_url(src, base_url) { Ok(u) => u, Err(_) => { eprintln!("[script] invalid URL: {src}"); @@ -192,6 +191,22 @@ fn fetch_script_text( } } +fn resolve_script_url(src: &str, base_url: &Url) -> Result { + we_url::Url::parse_with_base(src, base_url) + .or_else(|_| we_url::Url::parse(src)) + .map_err(|_| ()) +} + +fn install_module_specifier_resolver(import_map: &ImportMap, base_url: &Url) { + let import_map = import_map.clone(); + let base_url = base_url.clone(); + we_js::fetch::set_module_specifier_resolver(move |specifier| { + import_map + .resolve(specifier, &base_url) + .map(|url| url.serialize()) + }); +} + /// Parse a script as a classic script or an ES module per `kind`. fn parse_source(source: &str, label: &str, kind: ScriptKind) -> Option { let parsed = match kind { @@ -265,6 +280,24 @@ fn execute_source_with_dynamic_import_preloads( } } +fn execute_module_source( + vm: &mut Vm, + loader: &mut ResourceLoader, + document_origin: &Origin, + import_map: &ImportMap, + key: String, + base: Url, + source: String, +) { + crate::module_loader::load_and_execute_modules( + vm, + loader, + document_origin, + vec![crate::module_loader::EntryModule { key, base, source }], + import_map, + ); +} + /// Trim a script to its first ~120 chars of "meaningful" content for error /// logging. Strips leading whitespace, comment fences, and CDATA wrappers /// so the snippet starts with code the reader can recognise. @@ -374,6 +407,7 @@ pub fn execute_scripts_into_vm_with_console( we_js::fetch::set_document_origin(&origin_str); vm.set_document_origin(&origin_str); vm.set_document_url(base_url.clone()); + install_module_specifier_resolver(&import_map, base_url); we_js::fetch::set_host_fetch_handler(crate::loader::fetch_api_cached_response); load_service_worker_registrations(&mut vm, document_origin); load_cache_api_storage(document_origin, base_url); @@ -407,6 +441,7 @@ pub fn execute_scripts_into_vm_with_console( // Set the document URL so window.location properties work correctly. vm.set_document_url(base_url.clone()); + install_module_specifier_resolver(&import_map, base_url); we_js::fetch::set_host_fetch_handler(crate::loader::fetch_api_cached_response); load_service_worker_registrations(&mut vm, document_origin); load_cache_api_storage(document_origin, base_url); @@ -600,13 +635,26 @@ pub fn drain_event_loop_and_dynamic_scripts( document_origin: &Origin, ) { let csp = loader.csp().clone(); - let import_map = ImportMap::default(); + let import_map = vm + .borrow_document() + .map(|doc| { + let mut script_nodes = Vec::new(); + collect_script_nodes(&doc, doc.root(), &mut script_nodes); + let all_scripts: Vec<(NodeId, ScriptInfo)> = script_nodes + .into_iter() + .map(|node| (node, extract_script_info(&doc, node))) + .collect(); + extract_import_map(&all_scripts, base_url) + }) + .unwrap_or_default(); + install_module_specifier_resolver(&import_map, base_url); for _ in 0..MAX_ASYNC_DYNAMIC_SCRIPT_ROUNDS { let _ = vm.run_event_loop(25); - if !we_js::dom_bridge::has_pending_dynamic_scripts() { + if we_js::dom_bridge::has_pending_dynamic_scripts() { + drain_dynamic_scripts(vm, loader, base_url, document_origin, &csp, &import_map); + } else if !vm.has_pending_event_loop_work() { return; } - drain_dynamic_scripts(vm, loader, base_url, document_origin, &csp, &import_map); } if we_js::dom_bridge::has_pending_dynamic_scripts() { eprintln!( @@ -637,7 +685,9 @@ fn drain_dynamic_scripts( if kind == ScriptKind::Skip { continue; } + let mut script_url = None; let (source, label) = if let Some(src) = script.src { + script_url = resolve_script_url(&src, base_url).ok(); let policy = script .referrer_policy .as_deref() @@ -665,13 +715,43 @@ fn drain_dynamic_scripts( } else { continue; }; - let mut script_ctx = ScriptExecutionContext { - loader, - base_url, - document_origin, - import_map, - }; - execute_source_with_dynamic_import_preloads(vm, &mut script_ctx, &source, &label, kind); + + if kind == ScriptKind::Module { + let (key, module_base) = match script_url { + Some(url) => (url.serialize(), url), + None => ( + format!( + "{}#we-dynamic-module-{}", + base_url.serialize(), + script.node.index() + ), + base_url.clone(), + ), + }; + execute_module_source( + vm, + loader, + document_origin, + import_map, + key, + module_base, + source, + ); + } else { + let mut script_ctx = ScriptExecutionContext { + loader, + base_url, + document_origin, + import_map, + }; + execute_source_with_dynamic_import_preloads( + vm, + &mut script_ctx, + &source, + &label, + kind, + ); + } we_js::dom_bridge::fire_event_at_node(vm, script.node, "load", false, false); } // Each executed script may have scheduled microtasks/timers that @@ -1235,6 +1315,48 @@ mod tests { ); } + #[test] + fn delayed_load_event_script_executes_after_host_drain() { + // The host drain must not return just because no script has been + // queued yet; pending timers can append scripts on a later turn. + let html = r#" +
+ + "#; + let doc = parse_html(html); + let mut loader = ResourceLoader::new(); + let base_url = Url::parse("about:blank").unwrap(); + let origin = base_url.origin(); + let mut vm = execute_scripts_into_vm(doc, &mut loader, &base_url, &origin); + + vm.set_ready_state("complete"); + we_js::dom_bridge::fire_lifecycle_event( + &mut vm, + "load", + we_js::dom_bridge::LifecycleTarget::Window, + false, + false, + ); + drain_event_loop_and_dynamic_scripts(&mut vm, &mut loader, &base_url, &origin); + + let doc = vm.detach_document().unwrap_or_default(); + we_js::fetch::clear_document_origin(); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("delayed-postload"), + "scripts inserted by delayed load/timer callbacks should execute" + ); + } + #[test] fn dynamically_inserted_scripts_chain() { // The first inserted script inserts a second one; both must run, @@ -1502,6 +1624,182 @@ mod tests { ); } + #[test] + fn dynamically_inserted_external_module_script_links_imports() { + // es-module-shims feature detection appends a type=module data script + // whose body imports another generated module. The dynamic-script drain + // must use the module graph loader so those static imports are linked. + let html = r#" +
+ + "#; + let doc = run_scripts(html); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("linked"), + "dynamically inserted external modules should link their imports" + ); + } + + #[test] + fn dynamically_inserted_module_script_object_assign_properties_execute() { + // es-module-shims creates module scripts with + // `Object.assign(document.createElement("script"), { type, src })`. + // Object.assign must reflect those DOM properties before appendChild + // queues the script for host execution. + let html = r#" +
+ + "#; + let doc = run_scripts(html); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("assigned"), + "Object.assign-reflected module script properties should execute" + ); + } + + #[test] + fn dynamically_inserted_module_script_load_event_observes_namespace_assignment() { + // es-module-shims resolves its dynamic-import polyfill from the + // dynamically inserted module script's load event after the module body + // assigns a namespace object to `self._esmsi`. + let html = r#" +
+ + "#; + let doc = run_scripts(html); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("loaded"), + "dynamic module load events should fire after namespace assignment" + ); + } + + #[test] + fn post_load_dynamic_module_static_import_uses_import_map() { + // Dynamic module scripts inserted from load/timer callbacks still need + // the document import map when the module graph loader resolves their + // static imports. + let html = r#" + + + +
before
+ "#; + let doc = parse_html(html); + let mut loader = ResourceLoader::new(); + let base_url = Url::parse("https://example.test/page").unwrap(); + let origin = base_url.origin(); + let mut vm = execute_scripts_into_vm(doc, &mut loader, &base_url, &origin); + + vm.set_ready_state("complete"); + we_js::dom_bridge::fire_lifecycle_event( + &mut vm, + "load", + we_js::dom_bridge::LifecycleTarget::Window, + false, + false, + ); + drain_event_loop_and_dynamic_scripts(&mut vm, &mut loader, &base_url, &origin); + + let doc = vm.detach_document().unwrap_or_default(); + we_js::fetch::clear_document_origin(); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("mapped postload"), + "post-load dynamic module imports should resolve through the document import map" + ); + } + + #[test] + fn dynamic_module_load_event_microtasks_are_pumped() { + // es-module-shims chains Promise feature detection from a dynamic + // module script's load event. The host event-loop pump must run those + // microtasks before deciding dynamic script processing is complete. + let html = r#" +
+ + "#; + let doc = run_scripts(html); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("7:false:true"), + "microtasks queued by dynamic module load events should be pumped" + ); + } + #[test] fn module_imports_link_across_files() { // An inline module imports a binding from a `data:` module (which loads @@ -1587,4 +1885,31 @@ mod tests { "classic-script dynamic import should resolve from the preloaded module registry" ); } + + #[test] + fn classic_script_dynamic_import_bare_specifier_uses_import_map() { + // es-module-shims ultimately delegates supported dynamic imports to the + // native import() path. When the requested specifier is bare, the + // runtime resolver must use the same document import map as the module + // graph preloader, or the preloaded registry key will not match. + let html = r#" +
before
+ + + "#; + let doc = run_scripts(html); + assert_eq!( + find_text_by_id(&doc, doc.root(), "target").as_deref(), + Some("mapped dynamic"), + "dynamic import should resolve bare specifiers through the import map" + ); + } } diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 10a91d8..0e0f70e 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -740,6 +740,17 @@ fn object_assign(args: &[Value], ctx: &mut NativeContext) -> Result Vec { MICROTASK_QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut())) } +/// Whether promise reactions are waiting for a VM microtask checkpoint. +pub fn has_pending_microtasks() -> bool { + MICROTASK_QUEUE.with(|q| !q.borrow().is_empty()) +} + /// Install the current drain batch as the "in-flight" set, returning the /// previous batch (if any) so the caller can restore it after processing. /// The returned tasks are exposed to the GC via [`microtask_gc_roots`]. diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index a0a6687..00d896e 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -790,6 +790,54 @@ pub fn init_html_image_api(vm: &mut Vm) { vm.set_global("Image", Value::Function(ctor)); } +fn html_script_element_constructor( + _args: &[Value], + _ctx: &mut NativeContext, +) -> Result { + Err(RuntimeError::type_error( + "HTMLScriptElement constructor is not directly constructible", + )) +} + +fn html_script_element_supports( + args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let script_type = args + .first() + .map(|value| value.to_js_string(ctx.gc).to_ascii_lowercase()) + .unwrap_or_default(); + let supported = matches!(script_type.as_str(), "classic" | "module"); + Ok(Value::Boolean(supported)) +} + +/// Register `HTMLScriptElement.supports()` for script feature detection. +/// +/// Module shims use this for feature detection. The engine handles classic and +/// module scripts directly. Do not advertise native import-map support yet: +/// dynamic imports are still registry-backed rather than a complete native +/// lazy module loader, so shims need to process maps themselves. +pub fn init_html_script_element_api(vm: &mut Vm) { + let supports = make_native(&mut vm.gc, "supports", html_script_element_supports); + let ctor = vm.gc.alloc(HeapObject::Function(Box::new(FunctionData { + private_fields: std::collections::HashMap::new(), + name: "HTMLScriptElement".to_string(), + kind: FunctionKind::Native(NativeFunc { + callback: html_script_element_constructor, + }), + prototype_obj: None, + properties: HashMap::new(), + upvalues: Vec::new(), + }))); + if let Some(HeapObject::Function(fdata)) = vm.gc.get_mut(ctor) { + fdata.properties.insert( + "supports".to_string(), + Property::builtin(Value::Function(supports)), + ); + } + vm.set_global("HTMLScriptElement", Value::Function(ctor)); +} + /// Find ``, ``, and `` elements in the document. fn find_structural_elements(doc: &Document) -> (Option, Option, Option) { let mut html = None; @@ -5259,7 +5307,7 @@ pub fn handle_document_set( pub fn handle_dom_set( gc: &mut Gc, shapes: &mut ShapeTable, - bridge: &Rc, + bridge: &DomBridge, gc_ref: GcRef, key: &str, val: &Value, @@ -8653,6 +8701,27 @@ mod tests { } } + #[test] + fn test_html_script_element_supports_script_kinds() { + let result = eval_with_doc( + "", + r#" + typeof HTMLScriptElement + ":" + + typeof HTMLScriptElement.supports + ":" + + HTMLScriptElement.supports("classic") + ":" + + HTMLScriptElement.supports("module") + ":" + + HTMLScriptElement.supports("importmap") + ":" + + HTMLScriptElement.supports("speculationrules") + ":" + + HTMLScriptElement.supports("text/javascript") + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "function:function:true:true:false:false:false"), + v => panic!("expected HTMLScriptElement.supports summary, got {v:?}"), + } + } + #[test] fn test_document_images_collection() { let result = eval_with_doc( diff --git a/crates/js/src/fetch.rs b/crates/js/src/fetch.rs index b2e429f..c5e0bd0 100644 --- a/crates/js/src/fetch.rs +++ b/crates/js/src/fetch.rs @@ -43,6 +43,7 @@ pub struct HostFetchRequest { } type HostFetchHandler = Box Option>>; +type ModuleSpecifierResolver = Box Option>; /// A fetch that is in-flight or just completed. struct PendingFetch { @@ -61,6 +62,8 @@ thread_local! { static DOCUMENT_ORIGIN: RefCell> = const { RefCell::new(None) }; /// Serialized document URL used to resolve relative fetch() inputs. static DOCUMENT_BASE_URL: RefCell> = const { RefCell::new(None) }; + /// Embedder-provided resolver for dynamic import() module specifiers. + static MODULE_SPECIFIER_RESOLVER: RefCell> = RefCell::new(None); /// Embedder-provided fast path for requests already satisfied by the host. static HOST_FETCH_HANDLER: RefCell> = RefCell::new(None); } @@ -124,6 +127,7 @@ pub fn set_document_origin(origin: &str) { pub fn clear_document_origin() { DOCUMENT_ORIGIN.with(|o| *o.borrow_mut() = None); clear_host_fetch_handler(); + clear_module_specifier_resolver(); } /// Get the current document origin, if set. @@ -141,6 +145,26 @@ pub fn clear_document_base_url() { DOCUMENT_BASE_URL.with(|u| *u.borrow_mut() = None); } +/// Install an embedder-provided module specifier resolver for dynamic import(). +/// +/// The browser layer uses this to make runtime dynamic imports consult the +/// document import map. Returning `None` falls back to URL/base resolution. +pub fn set_module_specifier_resolver(resolver: F) +where + F: Fn(&str) -> Option + 'static, +{ + MODULE_SPECIFIER_RESOLVER.with(|r| { + *r.borrow_mut() = Some(Box::new(resolver)); + }); +} + +/// Clear the dynamic import module specifier resolver for the current thread. +pub fn clear_module_specifier_resolver() { + MODULE_SPECIFIER_RESOLVER.with(|r| { + r.borrow_mut().take(); + }); +} + /// Install an embedder-provided fetch handler for the current thread. /// /// The handler is consulted after local `data:`/`file:` handling and before @@ -457,7 +481,13 @@ fn parse_credentials_mode(mode: &str) -> we_net::cors::CredentialsMode { /// uses this so its registry key matches the key the static module loader /// publishes under (the dependency's resolved absolute URL). pub fn resolve_module_specifier(input: &str) -> String { - resolve_fetch_url(input.trim()) + let input = input.trim(); + if let Some(resolved) = + MODULE_SPECIFIER_RESOLVER.with(|r| r.borrow().as_ref().and_then(|resolver| resolver(input))) + { + return resolved; + } + resolve_fetch_url(input) } fn resolve_fetch_url(input: &str) -> String { diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index bcd0a3b..f597733 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -1804,6 +1804,7 @@ impl Vm { crate::dom_bridge::init_document_object(self); crate::dom_bridge::init_node_list_prototype(self); crate::dom_bridge::init_html_image_api(self); + crate::dom_bridge::init_html_script_element_api(self); crate::dom_bridge::init_event_system(self); crate::dom_bridge::init_storage_objects(self); crate::dom_bridge::init_canvas_path_api(self); @@ -2619,6 +2620,7 @@ impl Vm { /// Pump the event loop: drain due timers, completed fetches, IDB events, /// worker messages, and microtasks. pub fn pump_event_loop(&mut self) -> Result<(), RuntimeError> { + self.drain_microtasks()?; self.drain_due_timers()?; self.drain_completed_fetches()?; crate::dynamic_import::drain_dynamic_imports(self)?; @@ -2630,7 +2632,8 @@ impl Vm { crate::websocket::drain_websocket_events(self)?; crate::main_thread_worker::drain_worker_messages(self)?; crate::service_worker::drain_service_worker_events(self)?; - crate::broadcast_channel::drain_broadcast_messages(self) + crate::broadcast_channel::drain_broadcast_messages(self)?; + self.drain_microtasks() } /// Run the event loop until all pending timers, fetches, IDB events, and @@ -2639,7 +2642,24 @@ impl Vm { /// infinite loops with recurring intervals; pass 0 for unlimited. pub fn run_event_loop(&mut self, max_iterations: usize) -> Result<(), RuntimeError> { let mut iterations = 0; - while crate::timers::has_pending_timers() + while self.has_pending_event_loop_work() { + if max_iterations > 0 && iterations >= max_iterations { + break; + } + self.pump_event_loop()?; + iterations += 1; + // If work is still pending, sleep briefly to avoid spinning. + if self.has_pending_event_loop_work() { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + Ok(()) + } + + /// Whether host-managed asynchronous work is waiting for an event-loop turn. + pub fn has_pending_event_loop_work(&self) -> bool { + crate::builtins::has_pending_microtasks() + || crate::timers::has_pending_timers() || crate::fetch::has_pending_fetches() || crate::fetch::has_live_fetch_body_streams() || crate::dynamic_import::has_pending_dynamic_imports() @@ -2651,37 +2671,6 @@ impl Vm { || crate::main_thread_worker::has_live_workers() || crate::service_worker::has_pending_service_worker_events(self) || crate::broadcast_channel::has_live_channels() - { - if max_iterations > 0 && iterations >= max_iterations { - break; - } - self.drain_due_timers()?; - self.drain_completed_fetches()?; - crate::dynamic_import::drain_dynamic_imports(self)?; - crate::fetch::drain_fetch_body_streams(self)?; - crate::xml_http_request::drain_xhr_events(self)?; - crate::dom_bridge::drain_image_events(self)?; - crate::indexeddb::drain_idb_events(self)?; - crate::eventsource::drain_eventsource_events(self)?; - crate::websocket::drain_websocket_events(self)?; - crate::main_thread_worker::drain_worker_messages(self)?; - crate::service_worker::drain_service_worker_events(self)?; - crate::broadcast_channel::drain_broadcast_messages(self)?; - iterations += 1; - // If work is still pending, sleep briefly to avoid spinning. - if crate::timers::has_pending_timers() - || crate::fetch::has_pending_fetches() - || crate::fetch::has_live_fetch_body_streams() - || crate::dom_bridge::has_pending_image_events() - || crate::eventsource::has_live_event_sources() - || crate::websocket::has_live_websockets() - || crate::main_thread_worker::has_live_workers() - || crate::service_worker::has_pending_service_worker_events(self) - { - std::thread::sleep(std::time::Duration::from_millis(1)); - } - } - Ok(()) } /// Ensure the register file has at least `needed` slots.