diff --git a/moli-renderer-v8/src/modulepreload.rs b/moli-renderer-v8/src/modulepreload.rs index 494c7cf1c..9df16b0de 100644 --- a/moli-renderer-v8/src/modulepreload.rs +++ b/moli-renderer-v8/src/modulepreload.rs @@ -64,7 +64,13 @@ pub(crate) fn modulepreload_href(element: &Element) -> Option<&str> { } pub(crate) fn modulepreload_as_state(element: &Element) -> ModulepreloadAsState { - match link_as_destination(element.attribute("as")) { + modulepreload_as_state_from_attribute(element.attribute("as")) +} + +pub(crate) fn modulepreload_as_state_from_attribute( + as_attribute: Option<&str>, +) -> ModulepreloadAsState { + match link_as_destination(as_attribute) { LinkAsDestination::None | LinkAsDestination::Script | LinkAsDestination::AudioWorklet @@ -126,7 +132,11 @@ fn modulepreload_attributes_for_state(state: ModulepreloadAsState) -> Option bool { - let Some(media) = element.attribute("media").map(str::trim) else { + modulepreload_media_attribute_matches(element.attribute("media")) +} + +pub(crate) fn modulepreload_media_attribute_matches(media: Option<&str>) -> bool { + let Some(media) = media.map(str::trim) else { return true; }; if media.is_empty() { diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs index 5b542cd8e..8d158533e 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/preload_scanning_and_csp.rs @@ -134,6 +134,7 @@ fn html_preload_scanner_stops_module_preloads_after_import_map() { r#" + "#, @@ -1975,3 +1976,23 @@ fn buffered_preload_scanner_clears_nonnonceable_script_nonces() { "speculative requests must use the same nonceability gate as parser execution" ); } + +#[test] +fn html_preload_scanner_preserves_modulepreload_before_module_script() { + let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); + let requests = collect_preloadable_external_script_requests_from_html( + &final_url, + r#" + + + "#, + ); + + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].url, requests[1].url); + assert_eq!( + requests[0].fetch_metadata.integrity.as_deref(), + Some("sha384-invalid") + ); + assert_eq!(requests[1].fetch_metadata.integrity, None); +} diff --git a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/streaming_input.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/streaming_input.rs index de8ecf2da..01476dca4 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/streaming_input.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/streaming_input.rs @@ -28,27 +28,7 @@ fn buffered_html_preload_scan_collects_future_external_scripts_without_importmap ] ); } -#[test] -fn buffered_html_preload_scan_leaves_modulepreload_to_native_module_map() { - let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); - let requests = collect_preloadable_external_script_requests_from_html( - &final_url, - r#" - - - - - - - "#, - ); - assert_eq!( - requests, - Vec::new(), - "modulepreload must not enter the legacy script-text preload cache; the parser publishes exact link candidates to the native module map" - ); -} #[test] fn incremental_html_preload_scanner_handles_split_script_tag_boundaries() { let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); @@ -432,3 +412,35 @@ fn full_body_phase_one_parks_async_subresource_terminal_for_page_owner() { full_body_phase_one_parks_async_subresource_terminal_for_page_owner_inner, ); } + +#[test] +fn buffered_html_preload_scan_routes_script_like_modulepreloads_to_native_module_map() { + let final_url = Url::parse("https://example.test/docs/page.html").expect("test url"); + let requests = collect_preloadable_external_script_requests_from_html( + &final_url, + r#" + + + + + + + "#, + ); + + assert!( + requests + .iter() + .all(|request| request.kind_hint == crate::types::ScriptKind::Module), + "scanner modulepreloads must take the native module-map admission path" + ); + assert_eq!( + preload_request_urls(requests), + vec![ + Url::parse("https://example.test/entry.mjs").expect("entry module URL"), + Url::parse("https://example.test/theme.css?version=1") + .expect("default script-like module URL"), + ], + "the scanner should dedupe script-like modulepreloads while leaving typed and data candidates to the parser" + ); +} diff --git a/moli-renderer-v8/src/runtime/script_preloads.rs b/moli-renderer-v8/src/runtime/script_preloads.rs index 11d598ffb..de390a677 100644 --- a/moli-renderer-v8/src/runtime/script_preloads.rs +++ b/moli-renderer-v8/src/runtime/script_preloads.rs @@ -15,6 +15,10 @@ use url::Url; use moli_script::script_element_nonce_is_nonceable; use parking_lot::Mutex; +use crate::modulepreload::{ + ModulepreloadAsState, modulepreload_as_state_from_attribute, + modulepreload_media_attribute_matches, resolve_parser_network_resource_url, +}; use crate::network::ResourceRequestClient; use crate::page_task_queue::RendererOwnerWakeSender; use crate::parser::{PreparedScript, ScriptSource}; @@ -1410,6 +1414,58 @@ impl HtmlPreloadScannerSink { } } + fn maybe_collect_modulepreload(&self, tag: &Tag) { + if self.seen_import_map.get() + || (matches!(self.meta_csp_mode, MetaCspScannerMode::StopAfterMeta) + && self.seen_meta_csp_count.get() != 0) + { + return; + } + let Some(rel) = html_attr_value(&tag.attrs, "rel") else { + return; + }; + if !link_rel_includes_token(&rel, "modulepreload") + || modulepreload_as_state_from_attribute(html_attr_value(&tag.attrs, "as").as_deref()) + != ModulepreloadAsState::ScriptLike + || !modulepreload_media_attribute_matches( + html_attr_value(&tag.attrs, "media").as_deref(), + ) + { + return; + } + let Some(href) = html_attr_value(&tag.attrs, "href") else { + return; + }; + let Some(url) = resolve_parser_network_resource_url(&self.base_url.borrow(), &href) else { + return; + }; + let kind_hint = crate::types::ScriptKind::Module; + let mode_hint = crate::types::ScriptMode::ModuleInOrder; + let fetch_metadata = crate::planning::ScriptFetchMetadata::from_script_attributes( + html_attr_value(&tag.attrs, "crossorigin").as_deref(), + html_attr_value(&tag.attrs, "referrerpolicy").as_deref(), + None, + html_attr_value(&tag.attrs, "integrity").as_deref(), + html_attr_value(&tag.attrs, "nonce").as_deref(), + html_attr_value(&tag.attrs, "fetchpriority").as_deref(), + ); + let Some(key) = BufferedScriptPreloadKey::new(url.clone(), kind_hint, &fetch_metadata) + else { + return; + }; + let mut requests = self.requests.borrow_mut(); + if requests.seen.insert(BufferedPreloadKey::Script(key)) { + requests.script_requests.push(BufferedScriptPreloadRequest { + url, + initiator_url: self.final_url.clone(), + kind_hint, + mode_hint, + resource_type_hint: moli_fetch::RequestResourceType::Script, + fetch_metadata, + }); + } + } + fn maybe_update_base_url(&self, tag: &Tag) { if self.has_valid_base.get() { return; @@ -1630,6 +1686,7 @@ impl TokenSink for HtmlPreloadScannerSink { TokenSinkResult::RawData(ScriptData) } "link" => { + self.maybe_collect_modulepreload(&tag); self.maybe_collect_stylesheet_preload(&tag); TokenSinkResult::Continue }