Files
ldm0 1352c08f06 fix(parser): adapt suspension paths to current ownership model
Reuse the claimed stylesheet pause in deferred preparation and use the shared source suspension cause for popup streams. Exercise document.write resume ownership with a body stylesheet boundary so nested inline execution cannot bypass the intended suspension.
2026-10-02 13:12:00 +08:00

2828 lines
110 KiB
Rust

use html5ever::tendril::StrTendril;
use moli_dom::{
NodeId,
native::{DomHost, NativeDom, NativeNodeId},
};
use moli_page_types::{ScriptKind, ScriptMode, ScriptRun, ScriptSkipReason, ScriptSourceKind};
use moli_script::ScriptPreparationDisposition;
use moli_stylesheet_blocking::{
DocumentBlockingStylesheetSignature, StylesheetBlockingReadView,
collect_document_owned_blocking_stylesheets_before_in_view,
};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
};
use crate::script_planning::{
ParserPlanningReadView, PrepareScriptOutcome, PreparedImportMap, PreparedScript,
ScriptClassification, ScriptFilterSkipReason, build_prepared_import_map, build_prepared_script,
classify_parser_script,
};
use super::{
html::{
ParserBlockingStylesheetPause, ParserFinishDiscoverySignals, ParserInputContext,
ParserInputQueue, ParserInputSession, ParserPumpOutcome, ParserPumpStep,
ParserScriptElementStateTransition, ParserScriptHandoff, ParserScriptNoExecutionOutcome,
ParserScriptPreparationFailure, ParserScriptPreparationRequest, ParserYield,
},
live_target::{ParserRuntimeDomSinks, ParserStreamHtmlTreeSinkTarget},
session::{
HtmlParserSession, HtmlParserSessionResult, HtmlTreeSinkSession,
new_fragment_html_tree_sink_session, new_html_tree_sink_session,
},
};
pub(super) struct HtmlTreeSinkStream {
parser: HtmlParserSession,
script_input: ParserInputQueue,
parser_script_positions: RefCell<HashMap<NativeNodeId, usize>>,
next_parser_script_position: Cell<usize>,
defer_script_preparation: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RawParserStep {
OwnerInterrupted,
Script(NativeNodeId),
CustomElementConstruction,
BlockingStylesheet(NativeNodeId),
InputDrained,
}
#[derive(Debug, Clone)]
enum ParserScriptPreparation {
BlockingClassic(PreparedScript),
AsyncPostParse(PreparedScript),
NonAsyncPostParse(PreparedScript),
ImportMap(PreparedImportMap),
NoExecution(ParserScriptNoExecutionOutcome),
PreparationFailure(ParserScriptPreparationFailure),
}
#[derive(Debug, Clone, Copy)]
enum ParserScriptPreparationLane {
BlockingClassic,
AsyncPostParse,
NonAsyncPostParse,
}
impl ParserScriptPreparation {
fn async_prefetch_script(&self) -> Option<PreparedScript> {
match self {
Self::AsyncPostParse(script) => Some(script.clone()),
Self::BlockingClassic(_)
| Self::NonAsyncPostParse(_)
| Self::ImportMap(_)
| Self::NoExecution(_)
| Self::PreparationFailure(_) => None,
}
}
fn is_blocking_classic(&self) -> bool {
matches!(self, Self::BlockingClassic(_))
}
fn into_handoff(
self,
node_id: NativeNodeId,
start_line: u64,
start_column: u64,
blocking_signatures_before: std::collections::HashSet<DocumentBlockingStylesheetSignature>,
) -> ParserScriptHandoff {
match self {
Self::BlockingClassic(script) => ParserScriptHandoff::BlockingClassic {
node_id,
start_line,
start_column,
blocking_signatures_before,
script,
},
Self::AsyncPostParse(script) => ParserScriptHandoff::AsyncPostParse {
node_id,
start_line,
start_column,
script,
},
Self::NonAsyncPostParse(script) => ParserScriptHandoff::NonAsyncPostParse {
node_id,
start_line,
start_column,
blocking_signatures_before,
script,
},
Self::ImportMap(import_map) => ParserScriptHandoff::ImportMap {
node_id,
start_line,
start_column,
import_map,
},
Self::NoExecution(outcome) => ParserScriptHandoff::NoExecution {
node_id,
start_line,
start_column,
outcome,
},
Self::PreparationFailure(failure) => ParserScriptHandoff::PreparationFailure {
node_id,
start_line,
start_column,
failure,
},
}
}
}
fn unprepared_script_mode(
classification: &crate::script_planning::ScriptClassification,
) -> ScriptMode {
classification
.mode()
.unwrap_or_else(|| match classification.kind() {
ScriptKind::ImportMap => ScriptMode::ImportMapInOrder,
ScriptKind::DataBlock => ScriptMode::Normal,
ScriptKind::Classic | ScriptKind::Module => {
unreachable!("executable script kinds always have a mode")
}
})
}
fn ignored_parser_script(
position: usize,
mode: ScriptMode,
transition: ParserScriptElementStateTransition,
) -> ParserScriptPreparation {
ParserScriptPreparation::NoExecution(
ParserScriptNoExecutionOutcome::ignored(position, mode)
.with_element_state_transition(transition),
)
}
fn failed_parser_script(
position: usize,
mode: ScriptMode,
message: String,
transition: ParserScriptElementStateTransition,
) -> ParserScriptPreparation {
ParserScriptPreparation::PreparationFailure(
ParserScriptPreparationFailure::new(position, mode, message)
.with_element_state_transition(transition),
)
}
fn parser_script_report_url(
document: &impl ParserPlanningReadView,
classification: &ScriptClassification,
) -> Option<url::Url> {
let document_url = document.final_url_clone()?;
let Some(src) = classification.script.script_src.as_deref() else {
return Some(document_url);
};
Some(
document
.document_base_url_clone()
.unwrap_or_else(|| document_url.clone())
.join(src)
.unwrap_or(document_url),
)
}
fn skipped_parser_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
position: usize,
mode: ScriptMode,
classification: &ScriptClassification,
reason: ScriptSkipReason,
transition: ParserScriptElementStateTransition,
) -> ParserScriptPreparation {
let Some(url) = parser_script_report_url(document, classification) else {
return ignored_parser_script(position, mode, transition);
};
ParserScriptPreparation::NoExecution(
ParserScriptNoExecutionOutcome::skipped(
position,
mode,
ScriptRun::skipped(
NodeId::new(node_id.index()),
classification.kind(),
mode,
classification.source_kind,
url,
reason,
),
)
.with_element_state_transition(transition),
)
}
fn consume_parser_inserted_transition(
classification: &ScriptClassification,
) -> ParserScriptElementStateTransition {
if classification.script.parser_inserted {
ParserScriptElementStateTransition::ConsumeParserInserted {
force_async: !classification.script.async_attribute_present,
}
} else {
ParserScriptElementStateTransition::None
}
}
fn parser_filter_skip_reason(
classification: &ScriptClassification,
reason: ScriptFilterSkipReason,
) -> ScriptSkipReason {
match reason {
ScriptFilterSkipReason::AlreadyStarted => ScriptSkipReason::AlreadyStarted,
ScriptFilterSkipReason::NoModule => ScriptSkipReason::NoModule,
ScriptFilterSkipReason::DataBlock => ScriptSkipReason::UnsupportedType(
classification
.script
.script_type
.clone()
.unwrap_or_default(),
),
ScriptFilterSkipReason::LegacyEventForMismatch => ScriptSkipReason::UnsupportedType(
"legacy for/event script did not match window.onload".to_owned(),
),
}
}
fn prepare_parser_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
parser_position: Option<usize>,
) -> ParserScriptPreparation {
let connected = document.is_connected(node_id);
let position = parser_position.or_else(|| document.document_order_position(node_id));
let fallback_position = position.unwrap_or_else(|| document.script_handles().len());
let Some(c) = classify_parser_script(document, node_id) else {
return failed_parser_script(
fallback_position,
ScriptMode::Normal,
"script node disappeared during parser preparation".to_owned(),
ParserScriptElementStateTransition::None,
);
};
let classification_mode = unprepared_script_mode(&c);
if !connected {
return skipped_parser_script(
document,
node_id,
fallback_position,
classification_mode,
&c,
ScriptSkipReason::NotInMainDocument,
consume_parser_inserted_transition(&c),
);
}
if let Some(reason) = c.skip_reason() {
let transition = match reason {
ScriptFilterSkipReason::AlreadyStarted => ParserScriptElementStateTransition::None,
ScriptFilterSkipReason::DataBlock => consume_parser_inserted_transition(&c),
ScriptFilterSkipReason::NoModule | ScriptFilterSkipReason::LegacyEventForMismatch => {
ParserScriptElementStateTransition::MarkAlreadyStarted
}
};
return skipped_parser_script(
document,
node_id,
fallback_position,
classification_mode,
&c,
parser_filter_skip_reason(&c, reason),
transition,
);
}
if c.disposition == ScriptPreparationDisposition::ImportMap {
let Some(position) = position else {
return failed_parser_script(
fallback_position,
classification_mode,
"script node is not in document order during parser preparation".to_owned(),
ParserScriptElementStateTransition::MarkAlreadyStarted,
);
};
let Some(final_url) = document.final_url_clone() else {
return failed_parser_script(
position,
classification_mode,
"document URL missing during parser script preparation".to_owned(),
ParserScriptElementStateTransition::MarkAlreadyStarted,
);
};
let document_base_url = document
.document_base_url_clone()
.unwrap_or_else(|| final_url.clone());
return build_prepared_import_map(&c, final_url, document_base_url, node_id, position)
.map(ParserScriptPreparation::ImportMap)
.unwrap_or_else(|| {
failed_parser_script(
position,
classification_mode,
"failed to prepare parser import map".to_owned(),
ParserScriptElementStateTransition::MarkAlreadyStarted,
)
});
}
let Some((kind, mode)) = c.executable() else {
return ignored_parser_script(
fallback_position,
classification_mode,
consume_parser_inserted_transition(&c),
);
};
let lane = match (kind, mode, c.source_kind) {
(ScriptKind::Classic, ScriptMode::Normal, _) => {
ParserScriptPreparationLane::BlockingClassic
}
(ScriptKind::Classic, ScriptMode::Async, ScriptSourceKind::External)
| (ScriptKind::Module, ScriptMode::Async, _) => ParserScriptPreparationLane::AsyncPostParse,
(
ScriptKind::Classic,
ScriptMode::Defer | ScriptMode::ModuleDefer,
ScriptSourceKind::External,
)
| (ScriptKind::Module, _, _) => ParserScriptPreparationLane::NonAsyncPostParse,
_ => {
return ignored_parser_script(
fallback_position,
mode,
ParserScriptElementStateTransition::MarkAlreadyStarted,
);
}
};
let Some(position) = position else {
return failed_parser_script(
fallback_position,
mode,
"script node is not in document order during parser preparation".to_owned(),
ParserScriptElementStateTransition::MarkAlreadyStarted,
);
};
let Some(final_url) = document.final_url_clone() else {
return failed_parser_script(
position,
mode,
"document URL missing during parser script preparation".to_owned(),
ParserScriptElementStateTransition::MarkAlreadyStarted,
);
};
let document_base_url = document
.document_base_url_clone()
.unwrap_or_else(|| final_url.clone());
match build_prepared_script(&c, final_url, document_base_url, node_id, position) {
PrepareScriptOutcome::Prepared(script) => match lane {
ParserScriptPreparationLane::BlockingClassic => {
ParserScriptPreparation::BlockingClassic(*script)
}
ParserScriptPreparationLane::AsyncPostParse => {
ParserScriptPreparation::AsyncPostParse(*script)
}
ParserScriptPreparationLane::NonAsyncPostParse => {
ParserScriptPreparation::NonAsyncPostParse(*script)
}
},
PrepareScriptOutcome::UrlResolutionFailed(error)
| PrepareScriptOutcome::EmptyExternalSource(error) => {
ParserScriptPreparation::PreparationFailure(
ParserScriptPreparationFailure::external_source(position, mode, error)
.with_element_state_transition(
ParserScriptElementStateTransition::MarkAlreadyStarted,
),
)
}
PrepareScriptOutcome::EmptyInlineSource => skipped_parser_script(
document,
node_id,
position,
mode,
&c,
ScriptSkipReason::EmptyInlineScript,
consume_parser_inserted_transition(&c),
),
PrepareScriptOutcome::NonExecutableKind(_) => {
ignored_parser_script(position, mode, consume_parser_inserted_transition(&c))
}
}
}
pub(crate) fn prepare_parser_script_handoff_for_static_document(
document: &(impl ParserPlanningReadView + StylesheetBlockingReadView),
node_id: NativeNodeId,
start_line: u64,
start_column: u64,
) -> ParserScriptHandoff {
let blocking_signatures_before = collect_document_owned_blocking_stylesheets_before_in_view(
document,
NodeId::new(node_id.index()),
)
.into_iter()
.map(|blocker| blocker.signature().clone())
.collect();
prepare_parser_script(document, node_id, None).into_handoff(
node_id,
start_line,
start_column,
blocking_signatures_before,
)
}
impl HtmlTreeSinkStream {
pub(super) fn initialize_text_document(&self) {
self.parser.initialize_text_document();
}
fn from_session(session: HtmlTreeSinkSession) -> Self {
Self {
parser: session.parser,
script_input: session.script_input,
parser_script_positions: RefCell::default(),
next_parser_script_position: Cell::new(0),
defer_script_preparation: false,
}
}
pub fn defer_script_preparation_to_owner(&mut self) {
self.defer_script_preparation = true;
}
pub fn prepare_script(&self, request: ParserScriptPreparationRequest) -> ParserScriptHandoff {
let target = self.parser.sink().borrow_target();
prepare_parser_script(&*target, request.node_id, Some(request.position)).into_handoff(
request.node_id,
request.start_line,
request.start_column,
request.blocking_signatures_before,
)
}
pub(super) fn from_target_with_scripting(
target: ParserStreamHtmlTreeSinkTarget,
scripting_enabled: bool,
) -> Self {
Self::from_target_with_options(
target,
super::session::html_parse_opts_with_scripting(scripting_enabled),
)
}
pub(super) fn from_target_with_options(
target: ParserStreamHtmlTreeSinkTarget,
options: html5ever::ParseOpts,
) -> Self {
Self::from_session(new_html_tree_sink_session(target, options))
}
pub(super) fn from_fragment_target(
target: ParserStreamHtmlTreeSinkTarget,
context_handle: NativeNodeId,
context_namespace: &str,
context_local_name: &str,
scripting_enabled: bool,
) -> Self {
let session = new_fragment_html_tree_sink_session(
target,
context_handle,
context_namespace,
context_local_name,
scripting_enabled,
);
Self::from_session(session)
}
fn parser_script_position(&self, node_id: NativeNodeId) -> usize {
*self
.parser_script_positions
.borrow_mut()
.entry(node_id)
.or_insert_with(|| {
let position = self.next_parser_script_position.get();
self.next_parser_script_position
.set(position.saturating_add(1));
position
})
}
pub fn script_input_session(&self) -> ParserInputSession {
self.script_input.session()
}
pub fn enter_script_input_context(&self) -> ParserInputContext {
self.script_input_session()
.enter_pending_context(self.parser.current_input_insertion_point())
}
pub fn take_next_script_input(&self) -> Option<String> {
self.script_input.take_next_script_input()
}
pub fn next_script_input_len(&self) -> Option<usize> {
self.script_input.next_script_input_len()
}
pub fn snapshot_script_input(&self) -> String {
self.script_input.snapshot_script_input()
}
pub fn has_script_input(&self) -> bool {
self.script_input.has_script_input()
}
pub fn take_next_insertion_preload_input(&self) -> Option<String> {
self.script_input.take_next_insertion_preload_input()
}
pub fn take_processed_insertion_meta_csp_count(&self) -> usize {
self.script_input.take_processed_insertion_meta_csp_count()
}
pub fn feed(&self, chunk: &str) {
if chunk.is_empty() {
return;
}
self.parser.process(StrTendril::from(chunk));
}
pub fn pump_parser_step(&self, chunk: &str) -> ParserPumpOutcome {
self.pump_parser_step_with_source(chunk, false)
}
pub fn pump_parser_inserted_step(&self, chunk: &str) -> ParserPumpOutcome {
if chunk.is_empty() {
return ParserPumpOutcome {
result: ParserPumpStep::InputDrained,
discovered_async_prefetch_scripts: Vec::new(),
discovered_preload_link_candidates: Vec::new(),
discovered_blocking_stylesheet_inputs: Vec::new(),
};
}
self.pump_parser_step_with_source(chunk, true)
}
pub fn append_to_current_inserted_input(&self, chunk: &str) -> bool {
self.parser
.append_to_current_inserted_input(StrTendril::from(chunk))
}
pub fn append_at_current_insertion_point(&self, chunk: &str) {
self.parser
.append_at_current_insertion_point(StrTendril::from(chunk));
}
fn pump_parser_step_with_source(
&self,
chunk: &str,
inserted_source: bool,
) -> ParserPumpOutcome {
if !chunk.is_empty() {
if inserted_source {
self.parser.begin_inserted_input(StrTendril::from(chunk));
} else {
self.parser.push_back(StrTendril::from(chunk));
}
}
let tokenizer_result = self.parser.feed();
let paused_for_custom_element = matches!(
&tokenizer_result,
HtmlParserSessionResult::Script(handle)
if self.peek_pending_custom_element_construction_handoff_placeholder()
== Some(handle.node_id())
);
let paused_for_stylesheet = !paused_for_custom_element
&& matches!(
&tokenizer_result,
HtmlParserSessionResult::Script(handle)
if self.peek_pending_blocking_stylesheet_pause() == Some(handle.node_id())
);
// Claim this yield before discovery and script-preparation callbacks
// can register another stylesheet pause on the same live document.
let claimed_stylesheet_pause = paused_for_stylesheet
.then(|| self.pop_pending_blocking_stylesheet_pause())
.flatten();
let result = match tokenizer_result {
HtmlParserSessionResult::OwnerInterrupted => RawParserStep::OwnerInterrupted,
HtmlParserSessionResult::Script(handle) if paused_for_custom_element => {
debug_assert_eq!(
self.peek_pending_custom_element_construction_handoff_placeholder(),
Some(handle.node_id())
);
RawParserStep::CustomElementConstruction
}
HtmlParserSessionResult::Script(handle) if paused_for_stylesheet => {
debug_assert_eq!(claimed_stylesheet_pause, Some(handle.node_id()));
RawParserStep::BlockingStylesheet(handle.node_id())
}
HtmlParserSessionResult::Script(handle) => RawParserStep::Script(handle.node_id()),
HtmlParserSessionResult::InputDrained => RawParserStep::InputDrained,
};
let discovered_async_prefetch_candidate_node_ids =
self.drain_discovered_async_prefetch_candidates();
let discovered_preload_link_candidate_node_ids =
self.drain_discovered_preload_link_candidates();
let discovered_blocking_stylesheet_inputs =
self.drain_discovered_blocking_stylesheet_inputs();
let captured_blocking_stylesheet_signatures =
self.captured_blocking_stylesheet_signatures();
let discovered_async_prefetch_script_positions =
discovered_async_prefetch_candidate_node_ids
.iter()
.copied()
.map(|node_id| (node_id, self.parser_script_position(node_id)))
.collect::<HashMap<_, _>>();
let handoff_parser_position = match result {
RawParserStep::Script(node_id) => Some(self.parser_script_position(node_id)),
RawParserStep::OwnerInterrupted
| RawParserStep::CustomElementConstruction
| RawParserStep::BlockingStylesheet(_)
| RawParserStep::InputDrained => None,
};
let defer_script_preparation = self.defer_script_preparation
&& self
.parser
.sink()
.borrow_target()
.has_runtime_dom_consumer();
if defer_script_preparation {
// A speculative fetch is distinct from preparing a parser script.
// The live owner admits async execution only after its checkpoint;
// ordinary preload discovery continues on the separate preload lane.
let result = match result {
RawParserStep::OwnerInterrupted => {
ParserPumpStep::Yield(ParserYield::OwnerInterrupted)
}
RawParserStep::Script(node_id) => {
let target = self.parser.sink().borrow_target();
let (start_line, start_column) =
target.script_start_position(node_id).unwrap_or((0, 0));
ParserPumpStep::Yield(ParserYield::ScriptPreparation(Box::new(
ParserScriptPreparationRequest {
node_id,
start_line,
start_column,
position: handoff_parser_position.expect("script has parser position"),
needs_microtask_checkpoint: target
.read_is_html_element_named(node_id, "script"),
blocking_signatures_before: captured_blocking_stylesheet_signatures,
},
)))
}
RawParserStep::BlockingStylesheet(node_id) => {
assert_eq!(claimed_stylesheet_pause, Some(node_id));
ParserPumpStep::Yield(ParserYield::BlockingStylesheet(
ParserBlockingStylesheetPause { node_id },
))
}
RawParserStep::CustomElementConstruction => {
ParserPumpStep::Yield(ParserYield::CustomElementConstruction(Box::new(
self.pop_pending_custom_element_construction_handoff()
.expect("custom element boundary has a construction handoff"),
)))
}
RawParserStep::InputDrained => ParserPumpStep::InputDrained,
};
return ParserPumpOutcome {
result,
discovered_async_prefetch_scripts: Vec::new(),
discovered_preload_link_candidates: discovered_preload_link_candidate_node_ids,
discovered_blocking_stylesheet_inputs,
};
}
let (
result,
discovered_async_prefetch_scripts,
discovered_preload_link_candidates,
discovered_blocking_stylesheet_inputs,
) = {
let target = self.parser.sink().borrow_target();
let handoff_preparation = match result {
RawParserStep::Script(node_id) => {
let (start_line, start_column) =
target.script_start_position(node_id).unwrap_or((0, 0));
Some((
node_id,
start_line,
start_column,
captured_blocking_stylesheet_signatures.clone(),
prepare_parser_script(&*target, node_id, handoff_parser_position),
))
}
RawParserStep::OwnerInterrupted
| RawParserStep::CustomElementConstruction
| RawParserStep::BlockingStylesheet(_)
| RawParserStep::InputDrained => None,
};
let discovered_async_prefetch_scripts = discovered_async_prefetch_candidate_node_ids
.iter()
.filter_map(|node_id| {
if let Some((handoff_node_id, _, _, _, preparation)) =
handoff_preparation.as_ref()
&& handoff_node_id == node_id
{
preparation.async_prefetch_script()
} else {
prepare_parser_script(
&*target,
*node_id,
discovered_async_prefetch_script_positions
.get(node_id)
.copied(),
)
.async_prefetch_script()
}
})
.collect::<Vec<_>>();
let blocking_classic_handoff_node_id =
handoff_preparation
.as_ref()
.and_then(|(node_id, _, _, _, preparation)| {
preparation.is_blocking_classic().then_some(*node_id)
});
drop(target);
let result = if let (
RawParserStep::Script(_),
Some((node_id, start_line, start_column, blocking_signatures_before, preparation)),
) = (result, handoff_preparation)
{
ParserPumpStep::Yield(ParserYield::Script(Box::new(preparation.into_handoff(
node_id,
start_line,
start_column,
blocking_signatures_before,
))))
} else if let RawParserStep::BlockingStylesheet(node_id) = result {
assert_eq!(
claimed_stylesheet_pause,
Some(node_id),
"tokenizer stylesheet yield must consume its matching pending parser pause"
);
ParserPumpStep::Yield(ParserYield::BlockingStylesheet(
ParserBlockingStylesheetPause { node_id },
))
} else if let RawParserStep::OwnerInterrupted = result {
ParserPumpStep::Yield(ParserYield::OwnerInterrupted)
} else if let Some(handoff) = self.pop_pending_custom_element_construction_handoff() {
ParserPumpStep::Yield(ParserYield::CustomElementConstruction(Box::new(handoff)))
} else {
ParserPumpStep::InputDrained
};
debug_assert_eq!(
blocking_classic_handoff_node_id.is_some(),
matches!(
result,
ParserPumpStep::Yield(ParserYield::Script(ref handoff))
if matches!(handoff.as_ref(), ParserScriptHandoff::BlockingClassic { .. })
)
);
(
result,
discovered_async_prefetch_scripts,
discovered_preload_link_candidate_node_ids,
discovered_blocking_stylesheet_inputs,
)
};
ParserPumpOutcome {
result,
discovered_async_prefetch_scripts,
discovered_preload_link_candidates,
discovered_blocking_stylesheet_inputs,
}
}
pub fn finish_live_runtime_dom_sink_parser(self) -> ParserFinishDiscoverySignals {
let HtmlTreeSinkStream {
parser,
script_input: _,
parser_script_positions: _,
next_parser_script_position: _,
defer_script_preparation: _,
} = self;
parser.finish_live_runtime_dom_sink_parser()
}
pub fn with_stylesheet_blocking_read_view<R>(
&self,
f: impl FnOnce(&dyn StylesheetBlockingReadView) -> R,
) -> R {
let target = self.parser.sink().borrow_target();
f(&*target)
}
pub fn snapshot_parser_stream_document(&self) -> NativeDom {
self.parser.sink().snapshot_parser_stream_document()
}
pub fn snapshot_parser_stream_dom_host(&self) -> DomHost {
self.parser.sink().snapshot_parser_stream_dom_host()
}
pub fn take_parser_stream_null_custom_element_registry_elements(&self) -> Vec<NativeNodeId> {
self.parser
.sink()
.take_parser_stream_null_custom_element_registry_elements()
}
pub fn take_parser_stream_dom_host(&self) -> DomHost {
self.parser.sink().take_parser_stream_dom_host()
}
pub fn restore_parser_stream_dom_host(&self, dom_host: DomHost) {
self.parser.sink().restore_parser_stream_dom_host(dom_host);
}
pub(super) fn enter_runtime_dom_sinks_parse_step(&self, sinks: ParserRuntimeDomSinks) {
self.parser.sink().enter_runtime_dom_sinks_parse_step(sinks);
}
pub fn clear_runtime_dom_sinks_after_parse_step(&self) {
self.parser
.sink()
.clear_runtime_dom_sinks_after_parse_step()
}
pub fn replace_parser_stream_document(&self, document: NativeDom) {
self.parser.sink().replace_parser_stream_document(document);
}
pub fn drain_ready_parser_scripts(&self) -> Vec<NativeNodeId> {
self.parser.sink().drain_ready_parser_scripts()
}
pub fn drain_discovered_async_prefetch_candidates(&self) -> Vec<NativeNodeId> {
self.parser
.sink()
.drain_discovered_async_prefetch_candidates()
}
pub fn drain_discovered_preload_link_candidates(&self) -> Vec<NativeNodeId> {
self.parser
.sink()
.drain_discovered_preload_link_candidates()
}
pub fn drain_discovered_parser_meta_csp_candidates(&self) -> Vec<NativeNodeId> {
self.parser
.sink()
.drain_discovered_parser_meta_csp_candidates()
}
pub fn note_defined_autonomous_custom_element(&self, local_name: &str) {
self.parser
.sink()
.note_defined_autonomous_custom_element(local_name);
}
pub fn drain_pending_custom_element_construction_handoffs(
&self,
) -> Vec<crate::html::ParserCustomElementConstructionHandoff> {
self.parser
.sink()
.drain_pending_custom_element_construction_handoffs()
}
fn peek_pending_custom_element_construction_handoff_placeholder(&self) -> Option<NativeNodeId> {
self.parser
.sink()
.pending_custom_element_construction_handoff_placeholder()
}
fn pop_pending_custom_element_construction_handoff(
&self,
) -> Option<crate::html::ParserCustomElementConstructionHandoff> {
self.parser
.sink()
.pop_pending_custom_element_construction_handoff()
}
fn peek_pending_blocking_stylesheet_pause(&self) -> Option<NativeNodeId> {
self.parser.sink().pending_blocking_stylesheet_pause()
}
fn pop_pending_blocking_stylesheet_pause(&self) -> Option<NativeNodeId> {
self.parser.sink().pop_pending_blocking_stylesheet_pause()
}
fn drain_discovered_blocking_stylesheet_inputs(
&self,
) -> Vec<moli_stylesheet_blocking::DocumentOwnedBlockingStylesheetDiscoveryInput> {
self.parser
.sink()
.drain_discovered_blocking_stylesheet_inputs()
}
fn captured_blocking_stylesheet_signatures(
&self,
) -> std::collections::HashSet<DocumentBlockingStylesheetSignature> {
self.parser.sink().captured_blocking_stylesheet_signatures()
}
pub fn mark_script_already_started(&self, node_id: NativeNodeId) {
self.parser.sink().mark_script_already_started(node_id);
}
pub fn has_buffered_input(&self) -> bool {
self.parser.has_buffered_input()
}
pub fn buffered_input_len(&self) -> usize {
self.parser.buffered_input_len()
}
pub fn snapshot_buffered_input(&self) -> String {
self.parser.snapshot_buffered_input()
}
pub fn finish(self) -> NativeDom {
self.finish_dom_host().into_dom()
}
pub fn finish_dom_host(self) -> DomHost {
self.finish_dom_host_with_discovery_signals().0
}
pub(super) fn finish_dom_host_with_discovery_signals(
self,
) -> (DomHost, ParserFinishDiscoverySignals) {
let HtmlTreeSinkStream {
parser,
script_input: _,
parser_script_positions: _,
next_parser_script_position: _,
defer_script_preparation: _,
} = self;
let mut target = parser.finish();
let signals = ParserFinishDiscoverySignals {
parser_created_null_registry_elements: target
.take_parser_stream_null_custom_element_registry_elements(),
discovered_preload_link_candidates: target.drain_discovered_preload_link_candidates(),
discovered_parser_meta_csp_candidates: target
.drain_discovered_parser_meta_csp_candidates(),
discovered_blocking_stylesheet_inputs: target
.drain_discovered_blocking_stylesheet_inputs(),
};
(target.finish_dom_host(), signals)
}
}
#[cfg(test)]
mod tests {
use super::{ParserScriptPreparation, prepare_parser_script};
use crate::{
DocumentStream, HtmlParser, ParserPlanningReadView, ParserPumpStep, ParserScriptHandoff,
ParserYield, PreparedScript, ScriptSource,
};
use moli_dom::native::{DomHost, DomMutationEffects, NativeDom, NativeNodeId, Node};
use moli_page_types::{ScriptKind, ScriptMode, ScriptSkipReason, ScriptSourceKind};
use moli_stylesheet_blocking::DocumentBlockingStylesheetSignature;
use url::Url;
struct TestMutationEffectCollector<'a> {
host: *mut DomHost,
effects: &'a mut DomMutationEffects,
interrupt_when_body_text_is: Option<&'a str>,
}
impl crate::ParserDomReadConsumer for TestMutationEffectCollector<'_> {
fn node_exists(&mut self, node_id: NativeNodeId) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.node(node_id).is_some()
}
fn is_connected(&mut self, node_id: NativeNodeId) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.is_connected(node_id)
}
fn is_text_node(&mut self, node_id: NativeNodeId) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }
.node(node_id)
.and_then(moli_dom::native::Node::as_text)
.is_some()
}
fn owner_document(&mut self, node_id: NativeNodeId) -> Option<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.owner_document_handle(node_id)
}
fn parent_node(&mut self, node_id: NativeNodeId) -> Option<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }
.node(node_id)
.and_then(moli_dom::native::Node::parent_node)
}
fn previous_sibling(&mut self, node_id: NativeNodeId) -> Option<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }
.node(node_id)
.and_then(moli_dom::native::Node::prev_sibling)
}
fn last_child(&mut self, node_id: NativeNodeId) -> Option<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }
.node(node_id)
.and_then(moli_dom::native::Node::last_child)
}
fn child_handles(&mut self, node_id: NativeNodeId) -> Vec<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.child_handles(node_id).collect()
}
fn document_body_handle_for_document(
&mut self,
document_handle: NativeNodeId,
) -> Option<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.document_body_handle_for_document(document_handle)
}
fn template_contents_handle(&mut self, node_id: NativeNodeId) -> Option<NativeNodeId> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.parser_template_contents_handle(node_id)
}
fn is_html_element_named(&mut self, node_id: NativeNodeId, local_name: &str) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }
.dom()
.is_html_element_named(node_id, local_name)
}
fn is_external_async_classic_candidate(&mut self, node_id: NativeNodeId) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let Some(element) = (unsafe { &*self.host })
.node(node_id)
.and_then(moli_dom::native::Node::as_element)
else {
return false;
};
if !element.is_script_element() {
return false;
}
if element.script_source_attribute().is_none() || element.attribute("async").is_none() {
return false;
}
if element.is_html_script() && element.attribute("nomodule").is_some() {
return false;
}
let Some(script_type) = element.attribute("type") else {
return true;
};
if script_type.is_empty() {
return true;
}
moli_script::classify_script_kind(Some(script_type)) == ScriptKind::Classic
}
fn parser_script_read(&mut self, node_id: NativeNodeId) -> Option<crate::ParserScriptRead> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
<DomHost as ParserPlanningReadView>::parser_script_read(unsafe { &*self.host }, node_id)
}
fn stylesheet_element(
&mut self,
node_id: NativeNodeId,
) -> Option<moli_stylesheet_blocking::StylesheetElementRead> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
(unsafe { &*self.host })
.node(node_id)
.and_then(moli_stylesheet_blocking::StylesheetElementRead::from_node)
}
fn text_content(&mut self, node_id: NativeNodeId) -> Option<String> {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &*self.host }.text_content(node_id)
}
}
impl crate::ParserDomMutationConsumer for TestMutationEffectCollector<'_> {
fn apply_parser_dom_mutation(&mut self, mutation: crate::ParserDomMutation) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let effects = mutation.apply_to_dom_host(unsafe { &mut *self.host });
self.effects.merge(effects);
}
fn create_element_for_document_without_attributes(
&mut self,
document_handle: NativeNodeId,
local_name: String,
namespace: String,
prefix: Option<String>,
) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.create_element_without_attributes_for_document(
document_handle,
local_name,
namespace,
prefix,
)
}
fn create_parser_element_for_document_without_attributes(
&mut self,
construction: &moli_dom::native::ParserConstruction,
document_handle: NativeNodeId,
local_name: String,
namespace: String,
prefix: Option<String>,
) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
construction.create_element(
unsafe { &mut *self.host },
document_handle,
local_name,
namespace,
prefix,
)
}
fn add_attrs_if_missing_for_parser(
&mut self,
node_id: NativeNodeId,
attrs: Vec<moli_dom::native::Attribute>,
) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.add_attrs_if_missing_for_parser(node_id, attrs);
}
fn create_text_node(
&mut self,
document_handle: NativeNodeId,
text: String,
) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.create_text_node_for_document(document_handle, &text)
}
fn create_comment(&mut self, document_handle: NativeNodeId, text: String) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.create_comment_for_document(document_handle, &text)
}
fn create_processing_instruction(
&mut self,
document_handle: NativeNodeId,
target: String,
data: String,
) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.create_processing_instruction_for_document(
document_handle,
&target,
&data,
)
}
fn create_cdata_section(
&mut self,
document_handle: NativeNodeId,
data: String,
) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.create_cdata_section_for_document(document_handle, &data)
}
fn create_document_type(
&mut self,
document_handle: NativeNodeId,
name: String,
public_id: String,
system_id: String,
) -> NativeNodeId {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.create_document_type_for_document(
document_handle,
&name,
&public_id,
&system_id,
)
}
fn prepend_text_to_text_node(
&mut self,
node_id: NativeNodeId,
text: String,
) -> DomMutationEffects {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let host = unsafe { &mut *self.host };
let Some(previous) = host.node(node_id).and_then(Node::as_text) else {
return DomMutationEffects::default();
};
let mut merged = text;
merged.push_str(previous.data());
host.set_text_content_effects(node_id, &merged)
}
fn append_text_to_text_node(
&mut self,
node_id: NativeNodeId,
text: String,
) -> DomMutationEffects {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let host = unsafe { &mut *self.host };
let Some(previous) = host.node(node_id).and_then(Node::as_text) else {
return DomMutationEffects::default();
};
let mut merged = previous.data().to_owned();
merged.push_str(&text);
host.set_text_content_effects(node_id, &merged)
}
fn push_parse_error(&mut self, error: String) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.push_parse_error(error);
}
fn set_html_quirks_mode_for_parser(
&mut self,
quirks_mode: html5ever::tree_builder::QuirksMode,
) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.set_html_quirks_mode_for_parser(quirks_mode);
}
fn mark_script_already_started_for_parser(&mut self, node_id: NativeNodeId) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let _ = unsafe { &mut *self.host }.set_script_already_started(node_id, true);
}
fn mark_unclosed_form_control_for_parser(&mut self, node_id: NativeNodeId) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let _ = unsafe { &mut *self.host }.set_blocks_form_submission(node_id, true);
}
fn finish_parsing_children(
&mut self,
construction: &moli_dom::native::ParserConstruction,
node_id: NativeNodeId,
) {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
let effects = construction.finish_children(unsafe { &mut *self.host }, node_id);
self.effects.merge(effects);
}
fn attach_declarative_shadow_for_parser(
&mut self,
host_id: NativeNodeId,
template_id: NativeNodeId,
attrs: Vec<moli_dom::native::Attribute>,
) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.attach_declarative_shadow_for_parser(
host_id,
template_id,
&attrs,
)
}
fn associate_parser_form_owner(
&mut self,
target: NativeNodeId,
form: NativeNodeId,
) -> bool {
// SAFETY: the test keeps the DomHost alive for this parser pump step.
unsafe { &mut *self.host }.associate_parser_form_owner(target, form)
}
}
impl crate::ParserMutationEffectConsumer for TestMutationEffectCollector<'_> {
fn consume_parser_mutation_effects(&mut self, effects: DomMutationEffects) {
self.effects.merge(effects);
}
fn finish_parser_dom_mutations(&mut self) -> std::ops::ControlFlow<()> {
let Some(expected) = self.interrupt_when_body_text_is else {
return std::ops::ControlFlow::Continue(());
};
// SAFETY: the test owns the DomHost throughout this parser pump.
let host = unsafe { &*self.host };
let text = host
.document_body_handle_for_document(host.document_handle())
.and_then(|body| host.text_content(body));
if text.as_deref() == Some(expected) {
// Model a one-shot callback notification, not a level-triggered
// owner query. The adapter must retain it across input chunks.
self.interrupt_when_body_text_is = None;
std::ops::ControlFlow::Break(())
} else {
std::ops::ControlFlow::Continue(())
}
}
}
fn first_script_handle(html: &str) -> (NativeDom, NativeNodeId) {
let parser = HtmlParser::SCRIPTING_ENABLED;
let document = parser.parse(
Url::parse("https://example.test/").expect("test url should parse"),
html.to_owned(),
);
let handle = document
.script_handles()
.into_iter()
.next()
.expect("fixture should contain a script");
(document, handle)
}
fn prepare_parser_blocking_classic_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
) -> Option<PreparedScript> {
match prepare_parser_script(document, node_id, None) {
ParserScriptPreparation::BlockingClassic(script) => Some(script),
ParserScriptPreparation::AsyncPostParse(_)
| ParserScriptPreparation::NonAsyncPostParse(_)
| ParserScriptPreparation::ImportMap(_)
| ParserScriptPreparation::NoExecution(_)
| ParserScriptPreparation::PreparationFailure(_) => None,
}
}
fn prepare_parser_async_post_parse_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
) -> Option<PreparedScript> {
match prepare_parser_script(document, node_id, None) {
ParserScriptPreparation::AsyncPostParse(script) => Some(script),
ParserScriptPreparation::BlockingClassic(_)
| ParserScriptPreparation::NonAsyncPostParse(_)
| ParserScriptPreparation::ImportMap(_)
| ParserScriptPreparation::NoExecution(_)
| ParserScriptPreparation::PreparationFailure(_) => None,
}
}
fn prepare_parser_non_async_post_parse_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
) -> Option<PreparedScript> {
match prepare_parser_script(document, node_id, None) {
ParserScriptPreparation::NonAsyncPostParse(script) => Some(script),
ParserScriptPreparation::BlockingClassic(_)
| ParserScriptPreparation::AsyncPostParse(_)
| ParserScriptPreparation::ImportMap(_)
| ParserScriptPreparation::NoExecution(_)
| ParserScriptPreparation::PreparationFailure(_) => None,
}
}
fn prepare_parser_post_parse_script(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
) -> Option<PreparedScript> {
match prepare_parser_script(document, node_id, None) {
ParserScriptPreparation::AsyncPostParse(script)
| ParserScriptPreparation::NonAsyncPostParse(script) => Some(script),
ParserScriptPreparation::BlockingClassic(_)
| ParserScriptPreparation::ImportMap(_)
| ParserScriptPreparation::NoExecution(_)
| ParserScriptPreparation::PreparationFailure(_) => None,
}
}
fn prepare_parser_import_map(
document: &impl ParserPlanningReadView,
node_id: NativeNodeId,
) -> Option<crate::PreparedImportMap> {
match prepare_parser_script(document, node_id, None) {
ParserScriptPreparation::ImportMap(import_map) => Some(import_map),
ParserScriptPreparation::BlockingClassic(_)
| ParserScriptPreparation::AsyncPostParse(_)
| ParserScriptPreparation::NonAsyncPostParse(_)
| ParserScriptPreparation::NoExecution(_)
| ParserScriptPreparation::PreparationFailure(_) => None,
}
}
#[test]
fn parser_stream_feed_continues_past_definitive_encoding_indicator() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
stream.feed("<!doctype html><meta charset='utf-8'><body><p>after meta</p>");
let document = stream.snapshot_parser_stream_document();
assert_eq!(
document
.elements_by_tag_name(document.document_node_id(), "p", false)
.len(),
1,
"the advisory encoding indicator must not leave decoded input buffered"
);
}
#[test]
fn parser_stream_pump_continues_past_definitive_encoding_indicator() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome =
stream.pump_parser_step("<!doctype html><meta charset='utf-8'><body><p>after meta</p>");
assert!(matches!(outcome.result, ParserPumpStep::InputDrained));
let document = stream.snapshot_parser_stream_document();
assert_eq!(
document
.elements_by_tag_name(document.document_node_id(), "p", false)
.len(),
1,
"the parser pump must consume the tail after an encoding indicator"
);
}
#[test]
fn parser_stream_finish_continues_past_definitive_encoding_indicator() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step(concat!(
"<!doctype html><script>window.ready = true;</script>",
"<meta charset='utf-8'><p>after meta</p>"
));
assert!(matches!(
outcome.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
let document = stream.finish();
assert_eq!(
document
.elements_by_tag_name(document.document_node_id(), "p", false)
.len(),
1,
"finishing must consume the buffered tail after an encoding indicator"
);
}
#[test]
fn prepare_parser_blocking_classic_script_accepts_inline_classic() {
let (document, handle) = first_script_handle("<script>window.ran = true;</script>");
assert_eq!(
document
.node(handle)
.and_then(Node::as_element)
.map(|element| element.script_text_internal_slot()),
Some("window.ran = true;")
);
let prepared = prepare_parser_blocking_classic_script(&document, handle)
.expect("inline classic parser script should be prepared");
assert_eq!(prepared.kind, ScriptKind::Classic);
assert_eq!(prepared.mode, ScriptMode::Normal);
assert_eq!(prepared.source_kind, ScriptSourceKind::Inline);
}
#[test]
fn prepare_parser_svg_script_uses_shared_script_identity_and_href_source() {
let (document, handle) =
first_script_handle("<svg><script async href=\"/svg-script.js\"></script></svg>");
let element = document
.node(handle)
.and_then(Node::as_element)
.expect("SVG script element");
assert!(element.is_script_element());
assert_eq!(element.wrapper_prototype_name(), "SVGScriptElement");
assert_eq!(document.script_src(handle), Some("/svg-script.js"));
assert!(element.script_parser_inserted_for_prepare());
let prepared = prepare_parser_async_post_parse_script(&document, handle)
.expect("external async SVG script should be prepared");
assert_eq!(prepared.source_kind, ScriptSourceKind::External);
assert_eq!(prepared.url.as_str(), "https://example.test/svg-script.js");
}
#[test]
fn parser_finished_svg_script_seeds_trusted_source_state() {
let (document, handle) =
first_script_handle("<svg><script type=unknown>window.svgReady = true;</script></svg>");
let element = document
.node(handle)
.and_then(Node::as_element)
.expect("SVG script element");
assert_eq!(
element.script_text_internal_slot(),
"window.svgReady = true;"
);
assert!(!element.script_children_changed_by_api());
}
#[test]
fn prepare_parser_blocking_classic_script_rejects_external_defer() {
let (document, handle) = first_script_handle("<script defer src=\"/defer.js\"></script>");
assert!(prepare_parser_blocking_classic_script(&document, handle).is_none());
}
#[test]
fn prepare_parser_blocking_classic_script_rejects_external_async() {
let (document, handle) = first_script_handle("<script async src=\"/async.js\"></script>");
assert!(prepare_parser_blocking_classic_script(&document, handle).is_none());
}
#[test]
fn prepare_parser_blocking_classic_script_rejects_module_and_importmap() {
let (module_document, module_handle) =
first_script_handle("<script type=\"module\">export {};</script>");
assert!(prepare_parser_blocking_classic_script(&module_document, module_handle).is_none());
let (importmap_document, importmap_handle) = first_script_handle(
"<script type=\"importmap\">{\"imports\":{\"x\":\"/x.js\"}}</script>",
);
assert!(
prepare_parser_blocking_classic_script(&importmap_document, importmap_handle).is_none()
);
}
#[test]
fn prepare_parser_blocking_classic_script_rejects_already_started_script() {
let (mut document, handle) = first_script_handle("<script>window.ran = true;</script>");
let Some(node) = document.node_mut(handle) else {
panic!("script node should exist");
};
let Some(element) = node.data_mut().as_element_mut() else {
panic!("script handle should resolve to an element");
};
let _ = element.set_script_already_started(true);
assert!(prepare_parser_blocking_classic_script(&document, handle).is_none());
}
#[test]
fn prepare_parser_post_parse_script_accepts_external_defer() {
let (document, handle) = first_script_handle("<script defer src=\"/defer.js\"></script>");
let prepared = prepare_parser_post_parse_script(&document, handle)
.expect("external defer parser script should be prepared");
assert_eq!(prepared.kind, ScriptKind::Classic);
assert_eq!(prepared.mode, ScriptMode::Defer);
assert_eq!(prepared.source_kind, ScriptSourceKind::External);
}
#[test]
fn prepare_parser_post_parse_script_accepts_external_async() {
let (document, handle) = first_script_handle("<script async src=\"/async.js\"></script>");
let prepared = prepare_parser_post_parse_script(&document, handle)
.expect("external async parser script should be prepared");
assert_eq!(prepared.kind, ScriptKind::Classic);
assert_eq!(prepared.mode, ScriptMode::Async);
assert_eq!(prepared.source_kind, ScriptSourceKind::External);
}
#[test]
fn parser_preparation_separates_module_execution_from_import_map_registration() {
let (module_document, module_handle) =
first_script_handle("<script type=\"module\">window.moduleReady = true;</script>");
let prepared = prepare_parser_post_parse_script(&module_document, module_handle)
.expect("inline module parser script should be prepared");
assert_eq!(prepared.kind, ScriptKind::Module);
assert_eq!(prepared.mode, ScriptMode::ModuleDefer);
assert_eq!(prepared.source_kind, ScriptSourceKind::Inline);
let (importmap_document, importmap_handle) = first_script_handle(
"<script type=\"importmap\" nonce=\"map-nonce\">{\"imports\":{\"x\":\"/x.js\"}}</script>",
);
let prepared = prepare_parser_import_map(&importmap_document, importmap_handle)
.expect("inline importmap should produce a registration payload");
assert_eq!(prepared.nonce.as_deref(), Some("map-nonce"));
assert!(matches!(
prepared.source,
crate::PreparedImportMapSource::Inline(ref source)
if source == "{\"imports\":{\"x\":\"/x.js\"}}"
));
}
#[test]
fn parser_handoff_preserves_defer_modes_for_owner_acceptance() {
let classic_stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let classic = classic_stream
.pump_parser_step("<script defer src='/defer.js'></script>")
.result;
let ParserPumpStep::Yield(ParserYield::Script(classic)) = classic else {
panic!("expected classic defer parser handoff");
};
let ParserScriptHandoff::NonAsyncPostParse { script, .. } = *classic else {
panic!("classic defer must use the parser-owned post-parse handoff");
};
assert_eq!(script.kind, ScriptKind::Classic);
assert_eq!(script.mode, ScriptMode::Defer);
let module_stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let module = module_stream
.pump_parser_step("<script type='module'>export const value = 1;</script>")
.result;
let ParserPumpStep::Yield(ParserYield::Script(module)) = module else {
panic!("expected module-defer parser handoff");
};
let ParserScriptHandoff::NonAsyncPostParse { script, .. } = *module else {
panic!("module defer must use the parser-owned post-parse handoff");
};
assert_eq!(script.kind, ScriptKind::Module);
assert_eq!(script.mode, ScriptMode::ModuleDefer);
}
#[test]
fn parser_stream_assigns_stable_positions_to_shadow_root_scripts() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let first = stream
.pump_parser_step(
"<div><template shadowrootmode='open'><script>window.first = true</script><script>window.second = true</script></template></div>",
)
.result;
let ParserPumpStep::Yield(ParserYield::Script(first)) = first else {
panic!("expected first shadow-root script handoff");
};
let ParserScriptHandoff::BlockingClassic { script: first, .. } = *first else {
panic!("expected parser-blocking classic shadow-root script");
};
assert_eq!(first.position, 0);
assert!(
matches!(first.source, ScriptSource::Inline(ref source) if source.contains("window.first"))
);
let second = stream.pump_parser_step("").result;
let ParserPumpStep::Yield(ParserYield::Script(second)) = second else {
panic!("expected second shadow-root script handoff");
};
let ParserScriptHandoff::BlockingClassic { script: second, .. } = *second else {
panic!("expected second parser-blocking classic shadow-root script");
};
assert_eq!(second.position, 1);
assert!(
matches!(second.source, ScriptSource::Inline(ref source) if source.contains("window.second"))
);
}
#[test]
fn parser_stream_keeps_ordinary_template_scripts_inert() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let handoff = stream
.pump_parser_step(
"<template><script>window.templateScriptRan = true</script></template>",
)
.result;
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = handoff else {
panic!("expected template script handoff");
};
let ParserScriptHandoff::NoExecution { outcome, .. } = *handoff else {
panic!("ordinary template script must remain inert");
};
assert_eq!(
outcome.element_state_transition(),
crate::ParserScriptElementStateTransition::ConsumeParserInserted { force_async: true }
);
let (position, mode, run) = outcome.into_parts();
assert_eq!(position, 0);
assert_eq!(mode, ScriptMode::Normal);
assert!(matches!(
run,
Some(run)
if matches!(
run.outcome(),
moli_page_types::ScriptRunOutcome::Skipped(
ScriptSkipReason::NotInMainDocument
)
)
));
}
#[test]
fn parser_handoff_keeps_import_maps_out_of_executable_script_lanes() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let result = stream
.pump_parser_step(
"<script type='importmap' async defer>{\"imports\":{\"x\":\"/x.js\"}}</script>",
)
.result;
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else {
panic!("expected import-map parser handoff");
};
let ParserScriptHandoff::ImportMap { import_map, .. } = *handoff else {
panic!("import map must not use an executable parser handoff");
};
assert!(matches!(
import_map.source,
crate::PreparedImportMapSource::Inline(source)
if source.contains("\"x\":\"/x.js\"")
));
let external_stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let result = external_stream
.pump_parser_step("<script type='importmap' src='/map.json'></script>")
.result;
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else {
panic!("expected external import-map handoff");
};
assert!(matches!(
*handoff,
ParserScriptHandoff::ImportMap {
import_map: crate::PreparedImportMap {
source: crate::PreparedImportMapSource::ExternalUnsupported,
..
},
..
}
));
}
#[test]
fn prepare_parser_post_parse_script_accepts_async_module_via_async_lane() {
let (document, handle) = first_script_handle(
"<script type=\"module\" async>window.moduleReady = true;</script>",
);
let prepared = prepare_parser_async_post_parse_script(&document, handle)
.expect("async module parser script should use async post-parse lane");
assert_eq!(prepared.kind, ScriptKind::Module);
assert_eq!(prepared.mode, ScriptMode::Async);
assert_eq!(prepared.source_kind, ScriptSourceKind::Inline);
}
#[test]
fn prepare_parser_non_async_post_parse_script_rejects_async_modules() {
let (document, handle) = first_script_handle(
"<script type=\"module\" async>window.moduleReady = true;</script>",
);
assert!(
prepare_parser_non_async_post_parse_script(&document, handle).is_none(),
"async module script should not use non-async post-parse path"
);
}
#[test]
fn prepare_parser_post_parse_script_rejects_inline_and_normal_classic() {
let (inline_document, inline_handle) =
first_script_handle("<script>window.ran = true;</script>");
assert!(prepare_parser_post_parse_script(&inline_document, inline_handle).is_none());
let (normal_external_document, normal_external_handle) =
first_script_handle("<script src=\"/normal.js\"></script>");
assert!(
prepare_parser_post_parse_script(&normal_external_document, normal_external_handle)
.is_none()
);
}
#[test]
fn empty_src_parser_script_handoff_is_unprepared_not_fetchable() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome =
stream.pump_parser_step("<!doctype html><html><head><script src=\"\"></script>");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected parser script handoff");
};
let ParserScriptHandoff::PreparationFailure { failure, .. } = *handoff else {
panic!("empty src must not become a blocking classic fetch");
};
assert!(failure.is_external_source_failure());
assert_eq!(
failure.element_state_transition(),
crate::ParserScriptElementStateTransition::MarkAlreadyStarted
);
let (position, mode, message) = failure.into_parts();
assert_eq!(position, 0);
assert_eq!(mode, ScriptMode::Normal);
assert_eq!(message, "empty script src is not fetchable");
}
#[test]
fn internal_parser_script_preparation_failure_is_not_an_external_source_error() {
let failure = crate::ParserScriptPreparationFailure::new(
0,
ScriptMode::Normal,
"document URL missing during parser script preparation".to_owned(),
);
assert!(!failure.is_external_source_failure());
}
#[test]
fn invalid_src_parser_script_handoff_preserves_preparation_failure() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream
.pump_parser_step("<!doctype html><html><head><script src=\"http://[::1\"></script>");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected parser script handoff");
};
let ParserScriptHandoff::PreparationFailure { failure, .. } = *handoff else {
panic!("invalid src must stay a failed parser preparation");
};
assert!(failure.is_external_source_failure());
assert_eq!(
failure.element_state_transition(),
crate::ParserScriptElementStateTransition::MarkAlreadyStarted
);
let (position, mode, failure) = failure.into_parts();
assert_eq!(position, 0);
assert_eq!(mode, ScriptMode::Normal);
assert!(
failure.contains("failed to resolve script src"),
"invalid src failure should survive the parser boundary"
);
}
#[test]
fn parser_script_handoff_only_exposes_nonceable_nonces() {
fn handoff_nonce(markup: &str) -> Option<String> {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step(markup);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected parser script handoff for {markup:?}");
};
let ParserScriptHandoff::BlockingClassic { script, .. } = *handoff else {
panic!("expected blocking classic script for {markup:?}");
};
script.fetch_metadata.nonce
}
assert_eq!(
handoff_nonce("<script nonce=abc>safe()</script>"),
Some("abc".to_owned()),
"ordinary parser script nonce should remain usable"
);
for markup in [
"<script attribute<script nonce=abc>blocked()</script>",
"<script data-marker='value<StYlE' nonce=abc>blocked()</script>",
"<script data-marker='value<LiNk' nonce=abc>blocked()</script>",
"<script duplicate duplicate nonce=abc>blocked()</script>",
"<svg><script duplicate duplicate nonce=abc>blocked()</script></svg>",
] {
assert_eq!(
handoff_nonce(markup),
None,
"nonnonceable parser script must not expose its nonce: {markup:?}"
);
}
}
#[test]
fn parser_script_handoff_uses_html5ever_line_with_unknown_column_across_input_chunks() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let first = stream.pump_parser_step("<!doctype html>\r\n <scr");
assert!(matches!(first.result, ParserPumpStep::InputDrained));
let second = stream.pump_parser_step("ipt>window.ran = true;</script>");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = second.result else {
panic!("expected parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected blocking classic handoff");
};
assert_eq!(start_line, 2);
assert_eq!(start_column, 0);
}
#[test]
fn nonempty_parser_inserted_input_permanently_degrades_source_locations() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outer = stream.pump_parser_step(
"<!doctype html>\n<script>outer()</script>\n <script>original()</script>",
);
assert!(matches!(
outer.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
let inserted = stream.pump_parser_inserted_step("\n <script>inserted()</script>\n");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = inserted.result else {
panic!("expected inserted parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected inserted blocking classic handoff");
};
assert_eq!((start_line, start_column), (0, 0));
assert!(matches!(
stream.pump_parser_step("").result,
ParserPumpStep::InputDrained
));
let original = stream.pump_parser_step("");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = original.result else {
panic!("expected original parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected original blocking classic handoff");
};
assert_eq!((start_line, start_column), (0, 0));
}
#[test]
fn blocked_nested_writes_keep_each_input_at_its_insertion_depth() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outer = stream.pump_parser_step("<script>outer()</script>e");
assert!(matches!(
outer.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
let nested = stream.pump_parser_inserted_step("<script src='nested.js'></script>r");
assert!(matches!(
nested.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
assert!(stream.append_to_current_inserted_input("k"));
stream.append_to_end("d".to_owned());
stream
.script_input_session()
.enqueue_script_input_html("wo".to_owned());
assert_eq!(stream.snapshot_pending_input(), "worked");
}
#[test]
fn parser_token_crossing_inserted_and_original_input_stays_unknown() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outer = stream.pump_parser_step(
"<script>outer()</script>ipt>cross()</script>\n<script>original()</script>",
);
assert!(matches!(
outer.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
assert!(matches!(
stream.pump_parser_inserted_step("<scr").result,
ParserPumpStep::InputDrained
));
let cross_source = stream.pump_parser_step("");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = cross_source.result else {
panic!("expected cross-source parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected cross-source blocking classic handoff");
};
assert_eq!((start_line, start_column), (0, 0));
let original = stream.pump_parser_step("");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = original.result else {
panic!("expected following original parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected following original blocking classic handoff");
};
assert_eq!((start_line, start_column), (0, 0));
}
#[test]
fn parser_inserted_character_reference_chunks_degrade_without_disrupting_parsing() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let markup =
"<a href='?a=b&c=d&a0b=c&copy=1&noti=n&not=in&notin=&notin;&not;&;& &'>Link</a>";
for character in markup.chars() {
assert!(matches!(
stream
.pump_parser_inserted_step(&character.to_string())
.result,
ParserPumpStep::InputDrained
));
}
let outcome = stream.pump_parser_step("original text\n<script>original()</script>");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected original parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected original blocking classic handoff");
};
assert_eq!((start_line, start_column), (0, 0));
}
#[test]
fn parser_original_source_uses_html5ever_line_without_tracking_utf16_columns() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step("\n😀<script>original()</script>");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected blocking classic handoff");
};
assert_eq!((start_line, start_column), (2, 0));
}
#[test]
fn empty_parser_inserted_input_preserves_following_line_location() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outer =
stream.pump_parser_step("<script>outer()</script>\n<script>original()</script>");
assert!(matches!(
outer.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
assert!(matches!(
stream.pump_parser_inserted_step("").result,
ParserPumpStep::InputDrained
));
let original = stream.pump_parser_step("");
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = original.result else {
panic!("expected original parser script handoff");
};
let ParserScriptHandoff::BlockingClassic {
start_line,
start_column,
..
} = *handoff
else {
panic!("expected original blocking classic handoff");
};
assert_eq!((start_line, start_column), (2, 0));
}
#[test]
fn parser_stream_surfaces_inline_svg_script_handoff() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step(
"<!doctype html><body><svg><script>window.svgReady = true;</script></svg><p>late</p>",
);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected SVG script handoff");
};
let ParserScriptHandoff::BlockingClassic {
node_id, script, ..
} = *handoff
else {
panic!("expected inline SVG script to use the blocking classic lane");
};
let document = stream.snapshot_parser_stream_document();
let element = document
.node(node_id)
.and_then(Node::as_element)
.expect("SVG script element");
assert_eq!(element.wrapper_prototype_name(), "SVGScriptElement");
assert_eq!(script.source_kind, ScriptSourceKind::Inline);
assert_eq!(
document.script_text(node_id).as_deref(),
Some("window.svgReady = true;")
);
assert!(
document
.elements_by_tag_name(document.document_node_id(), "p", false)
.is_empty(),
"parser must not consume content after the SVG script handoff"
);
}
#[test]
fn parser_stream_surfaces_self_closing_external_svg_script_handoff() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step(
"<!doctype html><body><svg><script href='/self-closing.js'/></svg><p>late</p>",
);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected self-closing SVG script handoff");
};
let ParserScriptHandoff::BlockingClassic { script, .. } = *handoff else {
panic!("expected external SVG script to use the blocking classic lane");
};
assert_eq!(script.source_kind, ScriptSourceKind::External);
assert_eq!(script.url.as_str(), "https://example.test/self-closing.js");
let document = stream.snapshot_parser_stream_document();
assert!(
document
.elements_by_tag_name(document.document_node_id(), "p", false)
.is_empty(),
"parser must pause immediately after a self-closing SVG script"
);
}
#[test]
fn parser_stream_does_not_execute_svg_script_popped_by_an_ancestor_end_tag() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step(
"<!doctype html><body><svg><script>window.mustStayInert = true;</svg><p>after</p>",
);
assert!(matches!(outcome.result, ParserPumpStep::InputDrained));
let document = stream.snapshot_parser_stream_document();
assert_eq!(
document
.elements_by_tag_name(document.document_node_id(), "p", false)
.len(),
1,
"foreign stack reconciliation should still restore the HTML parent"
);
}
#[test]
fn data_block_handoff_consumes_parser_inserted_prepare_state() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let result = stream
.pump_parser_step("<script type='application/json'>{\"ok\":true}</script>")
.result;
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else {
panic!("expected data-block parser handoff");
};
let ParserScriptHandoff::NoExecution { outcome, .. } = *handoff else {
panic!("data block must remain non-executable");
};
assert_eq!(
outcome.element_state_transition(),
crate::ParserScriptElementStateTransition::ConsumeParserInserted { force_async: true }
);
assert!(matches!(
outcome.run(),
Some(run)
if matches!(
run.outcome(),
moli_page_types::ScriptRunOutcome::Skipped(
ScriptSkipReason::UnsupportedType(_)
)
)
));
}
#[test]
fn parser_stream_records_custom_element_construction_handoff_candidates() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
stream.note_defined_autonomous_custom_element("x-ready");
let outcome = stream.pump_parser_step(
"<!doctype html><body><x-ready id='first' data-probe='yes'></x-ready><x-late></x-late>",
);
let ParserPumpStep::Yield(ParserYield::CustomElementConstruction(handoff)) = outcome.result
else {
panic!("expected parser pump to surface custom element construction handoff");
};
let handoff = &*handoff;
assert_eq!(handoff.local_name, "x-ready");
assert_eq!(handoff.namespace, "http://www.w3.org/1999/xhtml");
assert_eq!(handoff.prefix, None);
assert_eq!(handoff.parent_at_creation, None);
assert_eq!(handoff.owner_document.index(), 0);
assert!(handoff.placeholder.index() > handoff.owner_document.index());
assert!(
handoff
.attributes
.iter()
.any(|attribute| attribute.name() == "id" && attribute.value() == "first")
);
assert!(
handoff
.attributes
.iter()
.any(|attribute| attribute.name() == "data-probe" && attribute.value() == "yes")
);
assert!(
stream
.drain_pending_custom_element_construction_handoffs()
.is_empty(),
"handoff queue should drain exactly once"
);
}
#[test]
fn parser_stream_surfaces_multiple_custom_element_handoffs_one_at_a_time() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
stream.note_defined_autonomous_custom_element("x-ready");
let first = stream.pump_parser_step(
"<!doctype html><body><x-ready id='a'></x-ready><x-ready id='b'></x-ready>",
);
let ParserPumpStep::Yield(ParserYield::CustomElementConstruction(first_handoff)) =
first.result
else {
panic!("expected first custom element construction handoff");
};
assert_eq!(first_handoff.local_name, "x-ready");
assert!(
first_handoff
.attributes
.iter()
.any(|attribute| attribute.name() == "id" && attribute.value() == "a")
);
let second = stream.pump_parser_step("");
let ParserPumpStep::Yield(ParserYield::CustomElementConstruction(second_handoff)) =
second.result
else {
panic!("expected second custom element construction handoff");
};
assert_eq!(second_handoff.local_name, "x-ready");
assert!(
second_handoff
.attributes
.iter()
.any(|attribute| attribute.name() == "id" && attribute.value() == "b")
);
assert!(matches!(
stream.pump_parser_step("").result,
ParserPumpStep::InputDrained
));
}
#[test]
fn parser_stream_custom_element_handoff_pauses_before_following_sibling_tokens() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
stream.note_defined_autonomous_custom_element("x-ready");
let first = stream.pump_parser_step(
"<!doctype html><body><x-ready id='a'></x-ready><x-late id='later'></x-late>",
);
let ParserPumpStep::Yield(ParserYield::CustomElementConstruction(first_handoff)) =
first.result
else {
panic!("expected custom element construction handoff");
};
assert_eq!(first_handoff.local_name, "x-ready");
let snapshot = stream.snapshot_parser_stream_document();
assert!(
snapshot.node(first_handoff.placeholder).is_some(),
"placeholder should exist at the handoff boundary"
);
assert!(
snapshot
.nodes()
.iter()
.all(|node| node.local_name() != Some("x-late")),
"parser must not consume following sibling tokens before custom element construction handoff"
);
}
#[test]
fn parser_stream_consumes_blocking_stylesheet_pause_before_resuming_tokenizer() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let first = stream.pump_parser_step(concat!(
"<!doctype html><body>",
"<link rel=stylesheet href='/slow.css'>",
"\n<script>window.afterStylesheet = true;</script>",
"<footer id=after>after</footer>",
"</body>"
));
let ParserPumpStep::Yield(ParserYield::BlockingStylesheet(pause)) = first.result else {
panic!("expected body stylesheet parser pause");
};
let paused_document = stream.snapshot_parser_stream_document();
assert!(
paused_document.node(pause.node_id).is_some(),
"stylesheet owner should exist at the parser pause boundary"
);
assert!(
paused_document
.elements_by_tag_name(paused_document.document_node_id(), "script", false)
.is_empty(),
"parser must not consume the following script start tag before the stylesheet settles"
);
assert!(
paused_document
.elements_by_tag_name(paused_document.document_node_id(), "footer", false)
.is_empty(),
"parser must retain the tail after the stylesheet pause"
);
let second = stream.pump_parser_step("");
assert!(
matches!(second.result, ParserPumpStep::Yield(ParserYield::Script(_))),
"resuming after the stylesheet pause should reach the following script"
);
assert!(matches!(
stream.pump_parser_step("").result,
ParserPumpStep::InputDrained
));
let completed_document = stream.snapshot_parser_stream_document();
assert_eq!(
completed_document
.elements_by_tag_name(completed_document.document_node_id(), "footer", false)
.len(),
1,
"parser should consume the retained tail after the script handoff"
);
}
#[test]
fn parser_stream_encoding_indicator_preserves_stylesheet_pause() {
for attributes in [
"charset=utf-8",
"charset=windows-1252",
"charset=unknown-encoding",
"http-equiv=content-type content='text/css; charset=utf-8'",
] {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let tail = "\n<script>window.afterStylesheet = true;</script><p>café</p>";
let first = stream.pump_parser_step(&format!(
"<!doctype html><body><link rel=stylesheet href='/slow.css' {attributes}>{tail}"
));
assert!(
matches!(
first.result,
ParserPumpStep::Yield(ParserYield::BlockingStylesheet(_))
),
"encoding notification must not bypass the stylesheet pause: {attributes}"
);
assert_eq!(first.discovered_blocking_stylesheet_inputs.len(), 1);
assert_eq!(stream.snapshot_pending_input(), tail);
let second = stream.pump_parser_step("");
assert!(matches!(
second.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
assert_eq!(stream.snapshot_pending_input(), "<p>café</p>");
assert!(matches!(
stream.pump_parser_step("").result,
ParserPumpStep::InputDrained
));
let document = stream.finish();
let paragraphs = document.elements_by_tag_name(document.document_node_id(), "p", false);
assert_eq!(paragraphs.len(), 1);
assert_eq!(
document.text_content(paragraphs[0]).as_deref(),
Some("café")
);
}
}
#[test]
fn parser_stream_feed_consumes_custom_element_handoff_without_runtime_owner() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
stream.note_defined_autonomous_custom_element("x-ready");
stream.feed("<!doctype html><body><x-ready id='a'></x-ready><x-late id='later'></x-late>");
let snapshot = stream.snapshot_parser_stream_document();
assert!(
stream
.drain_pending_custom_element_construction_handoffs()
.is_empty(),
"non-pump parsing has no runtime owner to consume parser-side handoffs"
);
assert!(
snapshot
.nodes()
.iter()
.any(|node| node.local_name() == Some("x-ready")),
"defined custom element token should still be parsed"
);
assert!(
snapshot
.nodes()
.iter()
.any(|node| node.local_name() == Some("x-late")),
"non-pump feed should continue parsing later sibling tokens"
);
}
#[test]
fn parser_stream_does_not_record_custom_element_handoff_without_runtime_definition() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page.html").expect("test url"),
);
let outcome = stream.pump_parser_step(
"<!doctype html><body><x-ready id='first'></x-ready><x-late></x-late>",
);
assert!(matches!(outcome.result, ParserPumpStep::InputDrained));
assert!(
stream
.drain_pending_custom_element_construction_handoffs()
.is_empty()
);
}
#[test]
fn prepare_parser_post_parse_script_rejects_data_block_and_already_started() {
let (data_block_document, data_block_handle) =
first_script_handle("<script type=\"application/json\">{\"ok\":true}</script>");
assert!(
prepare_parser_post_parse_script(&data_block_document, data_block_handle).is_none()
);
let (mut document, handle) =
first_script_handle("<script defer src=\"/defer.js\"></script>");
let Some(node) = document.node_mut(handle) else {
panic!("script node should exist");
};
let Some(element) = node.data_mut().as_element_mut() else {
panic!("script handle should resolve to an element");
};
let _ = element.set_script_already_started(true);
assert!(prepare_parser_post_parse_script(&document, handle).is_none());
}
#[test]
fn prepare_parser_helpers_can_read_from_live_dom_host() {
let (document, blocking_handle) =
first_script_handle("<script>window.ran = true;</script>");
let host = DomHost::from_dom(document);
let prepared = prepare_parser_blocking_classic_script(&host, blocking_handle)
.expect("live dom host should support parser-blocking planning reads");
assert_eq!(prepared.mode, ScriptMode::Normal);
assert_eq!(prepared.kind, ScriptKind::Classic);
let (document, defer_handle) =
first_script_handle("<script defer src=\"/defer.js\"></script>");
let host = DomHost::from_dom(document);
let prepared = prepare_parser_non_async_post_parse_script(&host, defer_handle)
.expect("live dom host should support parser post-parse planning reads");
assert_eq!(prepared.mode, ScriptMode::Defer);
assert_eq!(prepared.kind, ScriptKind::Classic);
}
#[test]
fn parser_stream_caches_document_url_before_runtime_dom_takeover() {
let url = Url::parse("https://example.test/page.html").expect("test url");
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(url.clone());
let dom_host = stream.take_parser_stream_dom_host();
let cached_url = stream.with_stylesheet_blocking_read_view(|view| view.final_url_clone());
assert_eq!(
cached_url.as_ref(),
Some(&url),
"parser planning should retain document URL after bootstrap DOM is taken by runtime"
);
stream.restore_parser_stream_dom_host(dom_host);
}
#[test]
fn parser_stream_reports_style_import_blocker_before_classic_handoff() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page").expect("test url"),
);
let outcome = stream.pump_parser_step(
"<!doctype html><html><head><style>@import url('/style.css');</style><script src='/app.js'></script></head></html>",
);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected parser-blocking classic handoff");
};
let ParserScriptHandoff::BlockingClassic {
blocking_signatures_before,
..
} = *handoff
else {
panic!("expected parser-blocking classic handoff");
};
let expected_url = Url::parse("https://example.test/style.css").unwrap();
assert_eq!(outcome.discovered_blocking_stylesheet_inputs.len(), 1);
assert!(
blocking_signatures_before
.iter()
.any(|signature| matches!(signature, DocumentBlockingStylesheetSignature::ParserCreatedStyleImport { urls } if urls == &vec![expected_url.clone()])),
"handoff should carry parser-created style import blocker signature"
);
}
#[test]
fn parser_style_children_finish_at_close_and_eof() {
for (opening, closing) in [
("<html><head><style id='sheet'>", "</style>"),
("<html><body><svg><style id='sheet'>", "</style></svg>"),
("<html><head><style id='sheet'>", ""),
] {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page").unwrap(),
);
stream.pump_parser_step(&format!("{opening}p {{ col"));
let document = stream.snapshot_parser_stream_document().into_document();
let style =
document.elements_by_tag_name(document.document_node_id(), "style", false)[0];
assert!(
document
.node(style)
.and_then(Node::as_element)
.unwrap()
.style_children_parsing()
);
stream.pump_parser_step(&format!("or: blue; }}{closing}"));
let document = if closing.is_empty() {
stream.finish()
} else {
stream.snapshot_parser_stream_document().into_document()
};
assert!(
!document
.node(style)
.and_then(Node::as_element)
.unwrap()
.style_children_parsing()
);
assert_eq!(
document.text_content(style).as_deref(),
Some("p { color: blue; }")
);
}
}
#[test]
fn parser_stream_reports_split_connected_meta_csp_once_and_ignores_template_contents() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page").expect("test url"),
);
let first = stream.pump_parser_step(r#"<meta http-equiv="Content-Security-"#);
assert!(matches!(first.result, ParserPumpStep::InputDrained));
assert!(
stream
.drain_discovered_parser_meta_csp_candidates()
.is_empty(),
"an incomplete tag must not publish a parser policy checkpoint"
);
let second = stream.pump_parser_step(
r#"Policy" content="script-src 'self'">
<template><meta http-equiv="content-security-policy" content="script-src 'none'"></template>
<script src="/app.js"></script>"#,
);
assert!(matches!(
second.result,
ParserPumpStep::Yield(ParserYield::Script(_))
));
let candidates = stream.drain_discovered_parser_meta_csp_candidates();
assert_eq!(candidates.len(), 1);
assert!(
stream
.drain_discovered_parser_meta_csp_candidates()
.is_empty(),
"a parser checkpoint must be consumed exactly once"
);
}
#[test]
fn parser_style_reports_parsing_finished_only_after_its_closing_tag() {
use moli_dom::native::DomStylesheetOwnerChangeKind;
for prefix in ["<!doctype html><head>", "<!doctype html><body><svg>"] {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/style-stream").unwrap(),
);
let mut host = stream.take_parser_stream_dom_host();
host.set_mutation_observer_records_enabled(true);
let mut effects = DomMutationEffects::default();
let mut consumer = TestMutationEffectCollector {
host: &mut host,
effects: &mut effects,
interrupt_when_body_text_is: None,
};
stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
&format!("{prefix}<style id=sheet>"),
&mut consumer,
);
let style = host.elements_by_tag_name(host.document_handle(), "style", true)[0];
*consumer.effects = DomMutationEffects::default();
// Separate feeds and line breaks both split tokenizer text. Neither
// may publish growing stylesheet prefixes to the renderer.
let chunk = ".rule { color: red; }\n".repeat(64);
for _ in 0..8 {
stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
&chunk,
&mut consumer,
);
assert!(consumer.effects.stylesheet_owners().changes().is_empty());
assert!(!consumer.effects.observer_records().records().is_empty());
*consumer.effects = DomMutationEffects::default();
}
stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
"</style>",
&mut consumer,
);
let changes = consumer.effects.stylesheet_owners().changes();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].owner(), style);
assert!(matches!(
changes[0].kind(),
DomStylesheetOwnerChangeKind::ParsingFinished
));
assert!(consumer.effects.observer_records().records().is_empty());
assert!(!host.is_style_element_parsing_children(style));
assert_eq!(host.text_content(style).unwrap(), chunk.repeat(8));
stream.restore_parser_stream_dom_host(host);
stream.finish();
}
}
#[test]
fn parser_stream_reports_style_import_blocker_on_runtime_dom_sinks() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/page").expect("test url"),
);
let mut dom_host = stream.take_parser_stream_dom_host();
let ptr = &mut dom_host as *mut DomHost;
let mut effects = DomMutationEffects::default();
let outcome = {
let mut collector = TestMutationEffectCollector {
host: ptr,
effects: &mut effects,
interrupt_when_body_text_is: None,
};
stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
"<!doctype html><html><head><style>@import url('/style.css');</style><script src='/app.js'></script></head></html>",
&mut collector,
)
};
stream.restore_parser_stream_dom_host(dom_host);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = outcome.result else {
panic!("expected parser-blocking classic handoff");
};
let ParserScriptHandoff::BlockingClassic {
blocking_signatures_before,
..
} = *handoff
else {
panic!("expected parser-blocking classic handoff");
};
let expected_url = Url::parse("https://example.test/style.css").unwrap();
assert!(
effects.did_change(),
"runtime DOM sink pump should report runtime-visible mutation effects through an explicit sink"
);
assert_eq!(outcome.discovered_blocking_stylesheet_inputs.len(), 1);
assert!(
blocking_signatures_before
.iter()
.any(|signature| matches!(signature, DocumentBlockingStylesheetSignature::ParserCreatedStyleImport { urls } if urls == &vec![expected_url.clone()])),
"handoff should carry parser-created style import blocker signature through runtime DOM sinks step"
);
}
#[test]
fn parser_callback_interruption_survives_character_tokens_and_input_boundaries() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/").unwrap(),
);
let mut host = stream.take_parser_stream_dom_host();
let mut effects = DomMutationEffects::default();
let mut consumer = TestMutationEffectCollector {
host: &mut host,
effects: &mut effects,
interrupt_when_body_text_is: Some("pause"),
};
let first = stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
"<!doctype html><body>pause",
&mut consumer,
);
assert!(consumer.interrupt_when_body_text_is.is_none());
assert!(matches!(first.result, ParserPumpStep::InputDrained));
let second = stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
"&amp;<!-- between -->more<span>tail</span>",
&mut consumer,
);
assert!(matches!(
second.result,
ParserPumpStep::Yield(ParserYield::OwnerInterrupted)
));
assert_eq!(stream.snapshot_pending_input(), "tail</span>");
let resumed = stream
.pump_parser_step_with_runtime_dom_consumer_without_element_creation("", &mut consumer);
assert!(matches!(resumed.result, ParserPumpStep::InputDrained));
stream.restore_parser_stream_dom_host(host);
let document = stream.finish();
let spans = document.elements_by_tag_name(document.document_node_id(), "span", false);
assert_eq!(spans.len(), 1);
assert_eq!(document.text_content(spans[0]).as_deref(), Some("tail"));
}
#[test]
fn parser_callback_interruption_at_text_eof_does_not_invent_a_script_handoff() {
let stream = DocumentStream::new_scripting_enabled_parser_stream_for_testing(
Url::parse("https://example.test/").unwrap(),
);
let mut host = stream.take_parser_stream_dom_host();
let mut effects = DomMutationEffects::default();
let mut consumer = TestMutationEffectCollector {
host: &mut host,
effects: &mut effects,
interrupt_when_body_text_is: Some("pause"),
};
let step = stream.pump_parser_step_with_runtime_dom_consumer_without_element_creation(
"<!doctype html><body>pause&am",
&mut consumer,
);
assert!(consumer.interrupt_when_body_text_is.is_none());
assert!(matches!(step.result, ParserPumpStep::InputDrained));
stream.restore_parser_stream_dom_host(host);
let document = stream.finish();
let bodies = document.elements_by_tag_name(document.document_node_id(), "body", false);
assert_eq!(
document.text_content(bodies[0]).as_deref(),
Some("pause&am")
);
}
}