From b6a751baaae5aacb5add669c3b715f0954aa1af2 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Wed, 1 Apr 2026 23:13:08 +0200 Subject: [PATCH] Implement speculative HTML parsing with off-main-thread preload scanning Add a speculative parser that runs on a background thread to discover preloadable resources while the main parser is blocked on script execution. - PreloadScanner: lightweight state machine that identifies , `. + ScriptData, + /// Saw `<` inside script data. + ScriptDataLt, + /// Accumulating potential `/script>` end tag inside script data. + ScriptDataEndTag, +} + +/// Lightweight preload scanner that extracts resource URLs from HTML. +/// +/// This is intentionally simplified: it does not handle all the edge cases +/// of the full HTML5 tokenizer. It is conservative — it may miss some +/// resources, but it will not produce false positives that cause incorrect +/// behaviour. +pub struct PreloadScanner { + input: Vec, + pos: usize, + state: ScanState, + /// The tag name being accumulated. + tag_name: String, + /// Current attribute name being accumulated. + attr_name: String, + /// Current attribute value being accumulated. + attr_value: String, + /// Collected attributes for the current tag. + attrs: Vec<(String, String)>, + /// Buffer for script end tag detection. + end_tag_buf: String, + /// Discovered preload URLs. + preloads: Vec, +} + +impl PreloadScanner { + /// Create a new preload scanner for the given input. + pub fn new(input: &str) -> Self { + PreloadScanner { + input: input.chars().collect(), + pos: 0, + state: ScanState::Data, + tag_name: String::new(), + attr_name: String::new(), + attr_value: String::new(), + attrs: Vec::new(), + end_tag_buf: String::new(), + preloads: Vec::new(), + } + } + + /// Run the scanner to completion, returning discovered preload URLs. + pub fn scan(&mut self) -> Vec { + while self.pos < self.input.len() { + self.step(); + } + std::mem::take(&mut self.preloads) + } + + /// Also produce a buffered token stream using the full tokenizer. + /// + /// This runs the full HTML5 tokenizer on the same input in addition to + /// the preload scan, buffering all tokens for potential reuse. + pub fn scan_with_tokens(&mut self) -> (Vec, Vec) { + // Run the preload scan. + let preloads = self.scan(); + + // Run the full tokenizer to buffer tokens. + let input_str: String = self.input.iter().collect(); + let mut tokenizer = crate::Tokenizer::new(&input_str); + let mut tokens = Vec::new(); + loop { + let token = tokenizer.next_token(); + if token == Token::Eof { + break; + } + tokens.push(token); + } + + (preloads, tokens) + } + + /// Advance the scanner by one character. + fn step(&mut self) { + let ch = self.input[self.pos]; + match self.state { + ScanState::Data => self.scan_data(ch), + ScanState::TagOpen => self.scan_tag_open(ch), + ScanState::EndTag => self.scan_end_tag(ch), + ScanState::TagName => self.scan_tag_name(ch), + ScanState::BeforeAttr => self.scan_before_attr(ch), + ScanState::AttrName => self.scan_attr_name(ch), + ScanState::AfterAttrName => self.scan_after_attr_name(ch), + ScanState::BeforeAttrValue => self.scan_before_attr_value(ch), + ScanState::AttrValueDQ => self.scan_attr_value_dq(ch), + ScanState::AttrValueSQ => self.scan_attr_value_sq(ch), + ScanState::AttrValueUQ => self.scan_attr_value_uq(ch), + ScanState::AfterAttrValue => self.scan_after_attr_value(ch), + ScanState::Comment => self.scan_comment(ch), + ScanState::CommentDash => self.scan_comment_dash(ch), + ScanState::CommentDashDash => self.scan_comment_dash_dash(ch), + ScanState::ScriptData => self.scan_script_data(ch), + ScanState::ScriptDataLt => self.scan_script_data_lt(ch), + ScanState::ScriptDataEndTag => self.scan_script_data_end_tag(ch), + } + } + + fn scan_data(&mut self, ch: char) { + if ch == '<' { + self.state = ScanState::TagOpen; + } + self.pos += 1; + } + + fn scan_tag_open(&mut self, ch: char) { + if ch == '/' { + self.state = ScanState::EndTag; + self.end_tag_buf.clear(); + self.pos += 1; + } else if ch == '!' { + // Could be comment: `"#; + let mut scanner = PreloadScanner::new(html); + let preloads = scanner.scan(); + assert_eq!(preloads.len(), 1); + assert_eq!(preloads[0].url, "/visible.png"); + } + + #[test] + fn scan_ignores_link_without_stylesheet_rel() { + let html = r#""#; + let mut scanner = PreloadScanner::new(html); + let preloads = scanner.scan(); + assert_eq!(preloads.len(), 0); + } + + #[test] + fn scan_handles_single_quoted_attrs() { + let html = ""; + let mut scanner = PreloadScanner::new(html); + let preloads = scanner.scan(); + assert_eq!(preloads.len(), 1); + assert_eq!(preloads[0].url, "/photo.jpg"); + } + + #[test] + fn scan_handles_unquoted_attrs() { + let html = ""; + let mut scanner = PreloadScanner::new(html); + let preloads = scanner.scan(); + assert_eq!(preloads.len(), 1); + assert_eq!(preloads[0].url, "/photo.jpg"); + } + + #[test] + fn scan_handles_self_closing_tags() { + let html = r#""#; + let mut scanner = PreloadScanner::new(html); + let preloads = scanner.scan(); + assert_eq!(preloads.len(), 1); + assert_eq!(preloads[0].url, "/photo.jpg"); + } + + #[test] + fn scan_case_insensitive_tag_names() { + let html = r#""#; + let mut scanner = PreloadScanner::new(html); + let preloads = scanner.scan(); + assert_eq!(preloads.len(), 2); + } + + #[test] + fn scan_with_tokens_produces_valid_tokens() { + let html = r#"

Hello

"#; + let mut scanner = PreloadScanner::new(html); + let (preloads, tokens) = scanner.scan_with_tokens(); + assert_eq!(preloads.len(), 0); + // Should have StartTag(p), Character(Hello), EndTag(p) + assert!(tokens.len() >= 3); + } + + #[test] + fn speculative_parser_basic_flow() { + let mut spec = SpeculativeParser::new(); + + let html = r#""#; + spec.speculate(html, 100); + + let results = spec.collect_results(); + assert!(results.is_some()); + let preloads = results.unwrap(); + assert_eq!(preloads.len(), 2); + assert_eq!(preloads[0].url, "/main.css"); + assert_eq!(preloads[1].url, "/hero.jpg"); + + // Should have buffered tokens. + assert!(spec.has_buffered_tokens()); + + spec.shutdown(); + } + + #[test] + fn speculative_parser_invalidation() { + let mut spec = SpeculativeParser::new(); + + let html = r#""#; + spec.speculate(html, 0); + + // Simulate document.write invalidating speculation. + spec.invalidate(); + assert!(spec.is_invalidated()); + assert!(!spec.has_buffered_tokens()); + + // Results should be discarded. + let results = spec.collect_results(); + assert!(results.is_none()); + + spec.shutdown(); + } + + #[test] + fn speculative_parser_token_replay() { + let mut spec = SpeculativeParser::new(); + + let html = r#"

Hello

"#; + spec.speculate(html, 0); + let _ = spec.collect_results(); + + // Replay buffered tokens. + let mut replayed = Vec::new(); + while let Some(token) = spec.next_buffered_token() { + replayed.push(token); + } + assert!(!replayed.is_empty()); + + spec.shutdown(); + } + + #[test] + fn speculative_parser_no_tokens_after_invalidation() { + let mut spec = SpeculativeParser::new(); + + let html = r#"

Hello

"#; + spec.speculate(html, 0); + let _ = spec.collect_results(); + + spec.invalidate(); + assert!(spec.next_buffered_token().is_none()); + + spec.shutdown(); + } + + #[test] + fn speculation_hit_no_document_write() { + // Simulates: main parser encounters