From 51455017c51d9c19081c80e1bb1e223761b087ca Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 19 Sep 2026 05:15:45 +0800 Subject: [PATCH] fix(parser): respect script nesting for stylesheet waits --- .../wpt-cross-current/passed-cases.txt | 1 + .../document_write_stylesheet_probe.js | 5 + moli-core/tests/scripts.rs | 3 + .../scripts/document_write_stylesheets.rs | 263 ++++++++++++++++++ moli-renderer-v8/src/document_runtime.rs | 4 + .../src/document_runtime/document_write.rs | 13 +- .../child_documents/live_parser.rs | 14 + 7 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 moli-core/tests/fixtures/runtime/document_write_stylesheet_probe.js create mode 100644 moli-core/tests/scripts/document_write_stylesheets.rs diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index cd82e2ac6b..7a3ceb06a6 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -7586,6 +7586,7 @@ html/semantics/scripting-1/the-script-element/json-module/parse-error.html html/semantics/scripting-1/the-script-element/json-module/script-element-json-src.html html/semantics/scripting-1/the-script-element/json-module/valid-content-type.html html/semantics/scripting-1/the-script-element/load-error-events-1.html +html/semantics/scripting-1/the-script-element/load-error-events-3.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror-module.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror.html html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-workerglobalscope-onerror.html diff --git a/moli-core/tests/fixtures/runtime/document_write_stylesheet_probe.js b/moli-core/tests/fixtures/runtime/document_write_stylesheet_probe.js new file mode 100644 index 0000000000..72546e4c9a --- /dev/null +++ b/moli-core/tests/fixtures/runtime/document_write_stylesheet_probe.js @@ -0,0 +1,5 @@ +styleNestingEvents.push('script'); +window.styleNestingRan = true; +window.styleNestingSheetAtRun = document.getElementById('gate').sheet !== null; +window.styleNestingCurrentScript = document.currentScript.id; +document.write('ok'); diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index 469e0cbf67..82c5b0a764 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -40,6 +40,9 @@ mod document_write_insertion; #[path = "scripts/document_write_errors.rs"] mod document_write_errors; +#[path = "scripts/document_write_stylesheets.rs"] +mod document_write_stylesheets; + #[path = "scripts/parser_cleanup.rs"] mod parser_cleanup; diff --git a/moli-core/tests/scripts/document_write_stylesheets.rs b/moli-core/tests/scripts/document_write_stylesheets.rs new file mode 100644 index 0000000000..cd51e74278 --- /dev/null +++ b/moli-core/tests/scripts/document_write_stylesheets.rs @@ -0,0 +1,263 @@ +use super::*; + +const SCRIPT: &str = include_str!("../fixtures/runtime/document_write_stylesheet_probe.js"); +const INITIALIZE: &str = "window.styleNestingEvents = []; window.styleNestingRan = false;"; +const OBSERVE_AND_RELEASE: &str = r#" + window.styleNestingRanAtReturn = styleNestingRan; + window.styleNestingRestoredScript = document.currentScript?.id ?? null; + styleNestingEvents.push('after-write'); + fetch('/assets/dynamic_blocking_stylesheet_script_executed'); +"#; + +fn markup_url(server: &FixtureServer, markup: &str) -> String { + let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document")).unwrap(); + url.query_pairs_mut().append_pair("markup", markup); + url.into() +} + +fn script_markup(id: &str, source: &str, external: bool) -> String { + if external { + let source = url::form_urlencoded::byte_serialize(source.as_bytes()) + .collect::() + .replace('+', "%20"); + format!("") + } else { + format!("") + } +} + +async fn stylesheet_wait_respects_parser_script_nesting( + child: bool, + script_created: bool, + nested: bool, + external: bool, + outer_external: bool, +) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let script = script_markup("inner", SCRIPT, external); + let written = format!( + "{script}" + ); + let fixture = if nested { + let written = serde_json::to_string(&written)?.replace('<', "\\u003c"); + let outer = script_markup( + "outer", + &format!("{INITIALIZE}document.write({written});{OBSERVE_AND_RELEASE}"), + outer_external, + ); + format!("{outer}
tail
") + } else { + format!("{written}
tail
") + }; + let initial = if script_created { + "" + } else { + &fixture + }; + let initial = if child { + format!( + "", + markup_url(&server, initial) + ) + } else { + initial.to_owned() + }; + let mut page = tokio::time::timeout( + Duration::from_secs(10), + browser.fetch(&markup_url(&server, &initial)), + ) + .await??; + let receiver = if child { + "document.getElementById('target').contentWindow" + } else { + "window" + }; + if script_created { + let initialize = if nested { "" } else { INITIALIZE }; + let observe = if nested { "" } else { OBSERVE_AND_RELEASE }; + let expression = format!( + r#"new Promise(resolve => {{ + const win = {receiver}; + const doc = win.document; + doc.open(); + win.eval({}); + doc.addEventListener('DOMContentLoaded', () => resolve(true), {{once: true}}); + doc.write({}); + win.eval({}); + doc.close(); + }})"#, + serde_json::to_string(initialize)?, + serde_json::to_string(&fixture)?, + serde_json::to_string(observe)?, + ); + tokio::time::timeout( + Duration::from_secs(10), + page.evaluate_runtime_expression_with_await_async(&expression, true), + ) + .await??; + } + let observed = page + .evaluate_runtime_expression_with_await_async( + &format!( + r#"(() => {{ + const win = {receiver}; + return JSON.stringify({{ + events: win.styleNestingEvents, + ranAtReturn: win.styleNestingRanAtReturn, + sheetAtRun: win.styleNestingSheetAtRun, + currentScript: win.styleNestingCurrentScript, + restoredScript: win.styleNestingRestoredScript, + written: win.document.getElementById('written')?.textContent, + tail: win.document.getElementById('tail')?.textContent + }}); + }})()"# + ), + true, + ) + .await?; + let observed: serde_json::Value = serde_json::from_str( + observed["value"] + .as_str() + .expect("stylesheet nesting observation"), + )?; + let immediate = nested && !external; + assert_eq!( + observed, + serde_json::json!({ + "events": if immediate { ["script", "after-write"] } else { ["after-write", "script"] }, + "ranAtReturn": immediate, + "sheetAtRun": !immediate, + "currentScript": "inner", + "restoredScript": if nested { Some("outer") } else { None }, + "written": "ok", + "tail": "tail" + }), + "child={child}, script_created={script_created}, nested={nested}, external={external}, outer_external={outer_external}" + ); + server.shutdown().await; + Ok(()) +} + +macro_rules! stylesheet_nesting_test { + ($name:ident, $child:expr, $created:expr, $nested:expr, $external:expr) => { + stylesheet_nesting_test!($name, $child, $created, $nested, $external, false); + }; + ($name:ident, $child:expr, $created:expr, $nested:expr, $external:expr, $outer_external:expr) => { + #[tokio::test(flavor = "multi_thread")] + async fn $name() -> Result<()> { + stylesheet_wait_respects_parser_script_nesting( + $child, + $created, + $nested, + $external, + $outer_external, + ) + .await + } + }; +} + +stylesheet_nesting_test!( + main_nested_inline_ignores_stylesheet_wait, + false, + false, + true, + false +); +stylesheet_nesting_test!( + child_nested_inline_ignores_stylesheet_wait, + true, + false, + true, + false +); +stylesheet_nesting_test!( + main_open_nested_inline_ignores_stylesheet_wait, + false, + true, + true, + false +); +stylesheet_nesting_test!( + child_open_nested_inline_ignores_stylesheet_wait, + true, + true, + true, + false +); +stylesheet_nesting_test!( + main_nested_external_keeps_stylesheet_wait, + false, + false, + true, + true +); +stylesheet_nesting_test!( + child_nested_external_keeps_stylesheet_wait, + true, + false, + true, + true +); +stylesheet_nesting_test!( + main_open_nested_external_keeps_stylesheet_wait, + false, + true, + true, + true +); +stylesheet_nesting_test!( + child_open_nested_external_keeps_stylesheet_wait, + true, + true, + true, + true +); +stylesheet_nesting_test!( + main_open_direct_inline_keeps_stylesheet_wait, + false, + true, + false, + false +); +stylesheet_nesting_test!( + child_open_direct_inline_keeps_stylesheet_wait, + true, + true, + false, + false +); + +stylesheet_nesting_test!( + main_external_outer_inline_ignores_stylesheet_wait, + false, + false, + true, + false, + true +); +stylesheet_nesting_test!( + child_external_outer_inline_ignores_stylesheet_wait, + true, + false, + true, + false, + true +); +stylesheet_nesting_test!( + main_open_external_outer_inline_ignores_stylesheet_wait, + false, + true, + true, + false, + true +); +stylesheet_nesting_test!( + child_open_external_outer_inline_ignores_stylesheet_wait, + true, + true, + true, + false, + true +); diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index 023cb67924..f7b8ad895e 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -291,6 +291,10 @@ impl ParserConnectedScriptBridge { self.parser_control.enter_parser_script_nesting() } + pub(crate) fn parser_script_nesting_level(&self) -> usize { + self.parser_control.parser_script_nesting_level() + } + pub(crate) fn run_state(&self) -> DocumentParserRunState { self.parser_control.run_state() } diff --git a/moli-renderer-v8/src/document_runtime/document_write.rs b/moli-renderer-v8/src/document_runtime/document_write.rs index 0bd58c3fa3..a3c4eb86a1 100644 --- a/moli-renderer-v8/src/document_runtime/document_write.rs +++ b/moli-renderer-v8/src/document_runtime/document_write.rs @@ -2268,9 +2268,16 @@ impl DocumentRuntime { blocking_signatures_before, script, } => { - if self.has_pending_parser_script_blocking_stylesheet_signatures( - blocking_signatures_before.iter(), - ) { + // Preparing an inline script while another parser script runs raises + // HTML's script nesting level above one, so it executes immediately. + let nested_inline = + matches!(script.source, crate::planning::ScriptSource::Inline(_)) + && parser_bridge.parser_script_nesting_level() > 0; + if !nested_inline + && self.has_pending_parser_script_blocking_stylesheet_signatures( + blocking_signatures_before.iter(), + ) + { if matches!(script.source, crate::planning::ScriptSource::External) { self.start_document_write_stylesheet_blocked_external_script( scope, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs index 5459b4d278..ba422d7900 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs @@ -1697,6 +1697,7 @@ impl JsContextHost { let ParserScriptHandoff::BlockingClassic { node_id, start_line, + blocking_signatures_before, script, .. } = handoff @@ -1707,6 +1708,19 @@ impl JsContextHost { (ScriptKind::Classic, ScriptSource::Inline(source)) => source.clone(), _ => return false, }; + // Only nested inline parser scripts bypass blocking stylesheets. A direct + // document.write() into a script-created parser must wait for them. + if !self.child_document_is_executing_parser_script(document_handle) + && self + .frame_owner_store + .current_child_document_owner(child_handle) + .is_some_and(|owner| { + self.frame_document_blocking_stylesheets + .blocks_signatures(owner, blocking_signatures_before.iter()) + }) + { + return false; + } let Some(mut job) = self.frame_owner_child_parser_classic_script_job( child_handle, Some(*node_id),