Improve render lifecycle, geometry, and PDF parity

This commit is contained in:
SGavrl committed 2026-08-04 00:32:43 +00:00
1 parent 50b2ea14ff
commit 68f20cf525
12 files changed
+3279 -411

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ pub use obscura_js::HTML_TO_MARKDOWN_JS;
pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion};
pub use page::{NetworkEvent, Page, PageError};
#[cfg(feature = "render")]
pub use pdf::{RasterPdfError, RasterPdfOptions};
pub use pdf::{RasterPdfError, RasterPdfOptions, RasterPdfPageRange};
// Re-exported so the embeddable `obscura` crate (which depends on obscura-browser,
// not obscura-js) can surface the interception channel types.
pub use obscura_js::ops::{InterceptResolution, InterceptedRequest};
+244 -21
View File
@@ -14,14 +14,28 @@ use crate::Page;
const POINTS_PER_INCH: f32 = 72.0;
const MAX_PAPER_INCHES: f32 = 200.0;
const MAX_PDF_PAGES: usize = 250;
// Page ranges may select a small bounded subset from a much longer document.
// Keep the arithmetic/index space finite without charging unselected pages
// against the output-page limit.
const MAX_PDF_DOCUMENT_PAGES: usize = 1_000_000;
const MAX_PDF_PAGE_PIXELS: u64 = 16 * 1024 * 1024;
const MAX_PDF_TOTAL_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RasterPdfPageRange {
/// One-based inclusive first page. `None` means the first page.
pub start: Option<usize>,
/// One-based inclusive last page. `None` means the final page.
pub end: Option<usize>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RasterPdfOptions {
pub landscape: bool,
pub print_background: bool,
pub scale: f32,
pub page_ranges: Vec<RasterPdfPageRange>,
pub paper_width_in: f32,
pub paper_height_in: f32,
pub margin_top_in: f32,
@@ -35,6 +49,8 @@ impl Default for RasterPdfOptions {
Self {
landscape: false,
print_background: false,
scale: 1.0,
page_ranges: Vec::new(),
paper_width_in: 8.5,
paper_height_in: 11.0,
// CDP's defaults are one centimetre.
@@ -52,6 +68,10 @@ pub enum RasterPdfError {
InvalidPaperSize,
#[error("PDF margins must be finite, non-negative, and leave a printable area")]
InvalidMargins,
#[error("PDF scale must be finite and between 0.1 and 2")]
InvalidScale,
#[error("PDF page ranges select no pages from this document")]
EmptyPageRange,
#[error("the page has no retained renderable document")]
NoRenderableDocument,
#[error("PDF pagination would exceed the {0}-page safety limit")]
@@ -85,7 +105,7 @@ struct PaginationPlan {
}
impl RasterPdfOptions {
fn page_geometry(self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> {
fn page_geometry(&self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> {
let values = [self.paper_width_in, self.paper_height_in];
if values
.iter()
@@ -137,8 +157,12 @@ fn pagination_plan(
content_height: f32,
printable_width: f32,
printable_height: f32,
scale: f32,
) -> Result<PaginationPlan, RasterPdfError> {
let points_per_css_pixel = printable_width / content_width;
if !scale.is_finite() || !(0.1..=2.0).contains(&scale) {
return Err(RasterPdfError::InvalidScale);
}
let points_per_css_pixel = printable_width / content_width * scale;
let css_page_height = printable_height / points_per_css_pixel;
if !points_per_css_pixel.is_finite()
|| points_per_css_pixel <= 0.0
@@ -149,10 +173,24 @@ fn pagination_plan(
}
let page_count_value = (content_height / css_page_height).ceil().max(1.0);
if !page_count_value.is_finite() || page_count_value > MAX_PDF_PAGES as f32 {
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
if !page_count_value.is_finite() || page_count_value > MAX_PDF_DOCUMENT_PAGES as f32 {
return Err(RasterPdfError::TooManyPages(MAX_PDF_DOCUMENT_PAGES));
}
let page_count = page_count_value as usize;
Ok(PaginationPlan {
points_per_css_pixel,
css_page_height,
page_count,
})
}
fn validate_selected_raster_work(
content_width: f32,
content_height: f32,
plan: PaginationPlan,
selected_pages: &[usize],
) -> Result<(), RasterPdfError> {
let pixel_width = content_width.ceil();
if !pixel_width.is_finite()
|| pixel_width <= 0.0
@@ -162,9 +200,12 @@ fn pagination_plan(
}
let pixel_width = pixel_width as u64;
let mut total_pixels = 0u64;
for page_index in 0..page_count {
let y = page_index as f32 * css_page_height;
let slice_height = (content_height - y).min(css_page_height).ceil();
for &page_index in selected_pages {
if page_index >= plan.page_count {
return Err(RasterPdfError::EmptyPageRange);
}
let y = page_index as f32 * plan.css_page_height;
let slice_height = (content_height - y).min(plan.css_page_height).ceil();
if !slice_height.is_finite()
|| slice_height <= 0.0
|| slice_height > obscura_js::MAX_CAPTURE_DIMENSION as f32
@@ -185,11 +226,49 @@ fn pagination_plan(
}
}
Ok(PaginationPlan {
points_per_css_pixel,
css_page_height,
page_count,
})
Ok(())
}
fn selected_page_indices(
page_count: usize,
ranges: &[RasterPdfPageRange],
) -> Result<Vec<usize>, RasterPdfError> {
if page_count == 0 {
return Err(RasterPdfError::EmptyPageRange);
}
if ranges.is_empty() {
if page_count > MAX_PDF_PAGES {
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
}
return Ok((0..page_count).collect());
}
let mut selected = std::collections::BTreeSet::new();
for range in ranges {
let start = range.start.unwrap_or(1);
let end = range.end.unwrap_or(page_count);
if start == 0 || end == 0 || start > end {
return Err(RasterPdfError::EmptyPageRange);
}
if start > page_count {
continue;
}
let end = end.min(page_count);
let span = end - start + 1;
if span > MAX_PDF_PAGES {
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
}
for page in start..=end {
selected.insert(page - 1);
if selected.len() > MAX_PDF_PAGES {
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
}
}
}
let selected = selected.into_iter().collect::<Vec<_>>();
if selected.is_empty() {
return Err(RasterPdfError::EmptyPageRange);
}
Ok(selected)
}
impl Page {
@@ -197,7 +276,7 @@ impl Page {
///
/// The full document width is scaled uniformly into the printable width;
/// vertical slices become pages. This does not reflow into `@media print`
/// or implement CSS paged media, headers, footers, or page ranges.
/// or implement CSS paged media, headers, or footers.
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
let (page_width, page_height, printable_width, printable_height, left, bottom) =
options.page_geometry()?;
@@ -221,16 +300,20 @@ impl Page {
content_height,
printable_width,
printable_height,
options.scale,
)?;
let selected_pages = selected_page_indices(plan.page_count, &options.page_ranges)?;
validate_selected_raster_work(content_width, content_height, plan, &selected_pages)?;
encode_pdf_pages(
plan.page_count,
selected_pages.len(),
page_width,
page_height,
left,
bottom,
printable_height,
|page_index| {
|output_page_index| {
let page_index = selected_pages[output_page_index];
let y = page_index as f32 * plan.css_page_height;
let slice_height = (content_height - y).min(plan.css_page_height);
let png = js
@@ -249,7 +332,7 @@ impl Page {
let rgb = decoded.into_rgb8();
Ok(RasterPage {
rgb,
draw_width_pt: printable_width,
draw_width_pt: content_width * plan.points_per_css_pixel,
draw_height_pt: slice_height * plan.points_per_css_pixel,
#[cfg(test)]
_lifetime_probe: None,
@@ -443,26 +526,166 @@ mod tests {
fn pagination_preflight_bounds_pages_and_raster_work() {
let (_, _, printable_width, printable_height, _, _) =
RasterPdfOptions::default().page_geometry().unwrap();
let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height)
let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height, 1.0)
.expect("an ordinary multi-page document stays inside the budget");
assert!(ordinary.page_count > 1);
let ordinary_pages = selected_page_indices(ordinary.page_count, &[]).unwrap();
validate_selected_raster_work(1280.0, 10_000.0, ordinary, &ordinary_pages).unwrap();
let oversized_page =
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height, 1.0).unwrap();
let oversized_page_selection =
selected_page_indices(oversized_page.page_count, &[]).unwrap();
assert_eq!(
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height).unwrap_err(),
validate_selected_raster_work(
5_000.0,
5_000.0,
oversized_page,
&oversized_page_selection,
)
.unwrap_err(),
RasterPdfError::RasterWorkLimitExceeded,
"one excessively large raster page must fail before capture"
);
let too_much_total =
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height, 1.0).unwrap();
let too_much_total_selection =
selected_page_indices(too_much_total.page_count, &[]).unwrap();
assert_eq!(
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height).unwrap_err(),
validate_selected_raster_work(
1_000.0,
70_000.0,
too_much_total,
&too_much_total_selection,
)
.unwrap_err(),
RasterPdfError::RasterWorkLimitExceeded,
"many individually valid pages must still respect a total work budget"
);
let too_many =
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height, 1.0).unwrap();
assert_eq!(
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height).unwrap_err(),
selected_page_indices(too_many.page_count, &[]).unwrap_err(),
RasterPdfError::TooManyPages(MAX_PDF_PAGES),
);
}
#[test]
fn selected_ranges_alone_determine_output_and_raster_budgets() {
let (_, _, printable_width, printable_height, _, _) =
RasterPdfOptions::default().page_geometry().unwrap();
let long =
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height, 1.0).unwrap();
assert!(long.page_count > MAX_PDF_PAGES);
let selected = selected_page_indices(
long.page_count,
&[RasterPdfPageRange {
start: Some(1),
end: Some(1),
}],
)
.unwrap();
assert_eq!(selected, vec![0]);
validate_selected_raster_work(1_000.0, 400_000.0, long, &selected).unwrap();
assert_eq!(
selected_page_indices(
long.page_count,
&[RasterPdfPageRange {
start: Some(1),
end: Some(MAX_PDF_PAGES + 1),
}],
),
Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES))
);
let base = pagination_plan(800.0, 2_000.0, printable_width, printable_height, 1.0)
.expect("base geometry");
let impossible_height =
base.css_page_height * (MAX_PDF_DOCUMENT_PAGES as f32 + 16.0);
assert_eq!(
pagination_plan(
800.0,
impossible_height,
printable_width,
printable_height,
1.0,
)
.unwrap_err(),
RasterPdfError::TooManyPages(MAX_PDF_DOCUMENT_PAGES)
);
}
#[test]
fn scale_changes_css_page_span_and_rejects_invalid_values() {
let (_, _, printable_width, printable_height, _, _) =
RasterPdfOptions::default().page_geometry().unwrap();
let normal =
pagination_plan(800.0, 2_000.0, printable_width, printable_height, 1.0).unwrap();
let enlarged =
pagination_plan(800.0, 2_000.0, printable_width, printable_height, 2.0).unwrap();
assert_eq!(
enlarged.points_per_css_pixel,
normal.points_per_css_pixel * 2.0
);
assert_eq!(enlarged.css_page_height, normal.css_page_height / 2.0);
assert!(enlarged.page_count >= normal.page_count);
assert_eq!(
pagination_plan(800.0, 2_000.0, printable_width, printable_height, 0.09,)
.unwrap_err(),
RasterPdfError::InvalidScale
);
}
#[test]
fn page_ranges_clip_deduplicate_and_preserve_document_order() {
assert_eq!(selected_page_indices(4, &[]).unwrap(), vec![0, 1, 2, 3]);
assert_eq!(
selected_page_indices(
6,
&[
RasterPdfPageRange {
start: Some(3),
end: Some(5),
},
RasterPdfPageRange {
start: Some(1),
end: Some(3),
},
RasterPdfPageRange {
start: Some(5),
end: None,
},
],
)
.unwrap(),
vec![0, 1, 2, 3, 4, 5]
);
assert_eq!(
selected_page_indices(
6,
&[RasterPdfPageRange {
start: None,
end: Some(2),
}],
)
.unwrap(),
vec![0, 1]
);
assert_eq!(
selected_page_indices(
3,
&[RasterPdfPageRange {
start: Some(9),
end: Some(12),
}],
),
Err(RasterPdfError::EmptyPageRange)
);
}
#[test]
fn writer_emits_xref_and_one_image_per_page() {
let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| {
+190 -15
View File
@@ -6,6 +6,10 @@ use crate::dispatch::CdpContext;
#[cfg(feature = "render")]
const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024;
#[cfg(feature = "render")]
const MAX_PAGE_RANGES_BYTES: usize = 16 * 1024;
#[cfg(feature = "render")]
const MAX_PAGE_RANGE_PARTS: usize = 512;
#[cfg(feature = "render")]
fn base64_encoded_len(raw_len: usize) -> Option<usize> {
@@ -20,7 +24,7 @@ enum PdfTransferMode {
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
struct ParsedPdfOptions {
raster: obscura_browser::RasterPdfOptions,
transfer_mode: PdfTransferMode,
@@ -49,6 +53,92 @@ fn boolean(params: &Value, name: &str, default: bool) -> Result<bool, String> {
}
}
#[cfg(feature = "render")]
fn parse_page_ranges(value: &str) -> Result<Vec<obscura_browser::RasterPdfPageRange>, String> {
let value = value.trim();
if value.is_empty() {
return Ok(Vec::new());
}
if value.len() > MAX_PAGE_RANGES_BYTES {
return Err(format!(
"Invalid parameters: pageRanges exceeds the {MAX_PAGE_RANGES_BYTES}-byte limit"
));
}
let mut ranges = Vec::new();
for (index, part) in value.split(',').enumerate() {
if index >= MAX_PAGE_RANGE_PARTS {
return Err(format!(
"Invalid parameters: pageRanges exceeds the {MAX_PAGE_RANGE_PARTS}-range limit"
));
}
let part = part.trim();
if part.is_empty() {
return Err("Invalid parameters: pageRanges contains an empty range".to_string());
}
let page = |text: &str| -> Result<Option<usize>, String> {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
text.parse::<usize>()
.ok()
.filter(|page| *page > 0)
.map(Some)
.ok_or_else(|| {
format!("Invalid parameters: pageRanges has invalid page {text:?}")
})
};
let (start, end) = match part.split_once('-') {
Some((start, end)) if !end.contains('-') => (page(start)?, page(end)?),
Some(_) => {
return Err(format!(
"Invalid parameters: pageRanges has invalid range {part:?}"
))
}
None => {
let page = page(part)?.ok_or_else(|| {
"Invalid parameters: pageRanges contains an empty page".to_string()
})?;
(Some(page), Some(page))
}
};
if start.is_none() && end.is_none() {
return Err("Invalid parameters: pageRanges range '-' is empty".to_string());
}
if matches!((start, end), (Some(start), Some(end)) if start > end) {
return Err(format!(
"Invalid parameters: pageRanges range {part:?} is descending"
));
}
ranges.push(obscura_browser::RasterPdfPageRange { start, end });
}
// Normalize before crossing the CDP/browser boundary. This bounds later
// selection work and makes repeated or overlapping user ranges occupy one
// entry while preserving Chromium's document-order, print-once semantics.
ranges.sort_by_key(|range| (range.start.unwrap_or(1), range.end.unwrap_or(usize::MAX)));
let mut normalized: Vec<obscura_browser::RasterPdfPageRange> = Vec::new();
for range in ranges {
let start = range.start.unwrap_or(1);
if let Some(previous) = normalized.last_mut() {
let previous_end = previous.end.unwrap_or(usize::MAX);
if start <= previous_end.saturating_add(1) {
previous.end = match (previous.end, range.end) {
(None, _) | (_, None) => None,
(Some(previous), Some(next)) => Some(previous.max(next)),
};
continue;
}
}
normalized.push(obscura_browser::RasterPdfPageRange {
start: Some(start),
end: range.end,
});
}
Ok(normalized)
}
#[cfg(feature = "render")]
fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
if !params.is_object() {
@@ -72,20 +162,18 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
// unusable. Accept and truthfully report `taggedPdf:false` in the response.
let requested_tagged_pdf = boolean(params, "generateTaggedPDF", false)?;
let scale = number(params, "scale", 1.0)?;
if (scale - 1.0).abs() > f32::EPSILON {
return Err(
"Page.printToPDF scale is not supported by the raster paginator; only scale=1 is accepted"
.to_string(),
);
if !(0.1..=2.0).contains(&scale) {
return Err("Invalid parameters: scale must be between 0.1 and 2".to_string());
}
if let Some(value) = params.get("pageRanges") {
let ranges = value
.as_str()
.ok_or("Invalid parameters: pageRanges must be a string")?;
if !ranges.trim().is_empty() {
return Err("Page.printToPDF pageRanges are not yet supported".to_string());
let page_ranges = match params.get("pageRanges") {
Some(value) => {
let ranges = value
.as_str()
.ok_or("Invalid parameters: pageRanges must be a string")?;
parse_page_ranges(ranges)?
}
}
None => Vec::new(),
};
for name in ["headerTemplate", "footerTemplate"] {
if let Some(value) = params.get(name) {
let template = value
@@ -111,6 +199,8 @@ fn parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
raster: obscura_browser::RasterPdfOptions {
landscape: boolean(params, "landscape", defaults.landscape)?,
print_background: requested_print_background,
scale,
page_ranges,
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
@@ -148,6 +238,8 @@ pub async fn print_to_pdf(
"obscuraCapabilities": {
"cssPagedMedia": false,
"honorsPrintBackground": true,
"honorsScale": true,
"pageRanges": true,
"printColorAdjustExact": false,
"taggedPdf": false,
},
@@ -212,17 +304,78 @@ mod tests {
assert!(!standard.requested_print_background);
assert!(!standard.raster.print_background);
assert!(standard.requested_tagged_pdf);
assert_eq!(standard.raster.scale, 1.0);
assert!(standard.raster.page_ranges.is_empty());
for params in [
json!({"displayHeaderFooter": true}),
json!({"preferCSSPageSize": true}),
json!({"scale": 0.5}),
json!({"pageRanges": "2-3"}),
json!({"headerTemplate": "<span>title</span>"}),
json!({"generateDocumentOutline": true}),
] {
assert!(parse_options(&params).is_err(), "must reject {params}");
}
let ranged = parse_options(&json!({
"scale": 0.5,
"pageRanges": "1, 3-5, 8-",
}))
.expect("CDP scale and page ranges");
assert_eq!(ranged.raster.scale, 0.5);
assert_eq!(
ranged.raster.page_ranges,
vec![
obscura_browser::RasterPdfPageRange {
start: Some(1),
end: Some(1),
},
obscura_browser::RasterPdfPageRange {
start: Some(3),
end: Some(5),
},
obscura_browser::RasterPdfPageRange {
start: Some(8),
end: None,
},
]
);
assert_eq!(
parse_page_ranges("5-8, 1-3, 3-6, 10, 9, 12-").unwrap(),
vec![
obscura_browser::RasterPdfPageRange {
start: Some(1),
end: Some(10),
},
obscura_browser::RasterPdfPageRange {
start: Some(12),
end: None,
},
],
"overlapping, adjacent, and repeated ranges must normalize before pagination"
);
for params in [
json!({"scale": 0.09}),
json!({"scale": 2.01}),
json!({"pageRanges": "0"}),
json!({"pageRanges": "3-2"}),
json!({"pageRanges": "1,,2"}),
json!({"pageRanges": "1-2-3"}),
] {
assert!(parse_options(&params).is_err(), "must reject {params}");
}
let too_many_parts = std::iter::repeat_n("1", MAX_PAGE_RANGE_PARTS + 1)
.collect::<Vec<_>>()
.join(",");
assert!(
parse_page_ranges(&too_many_parts).is_err(),
"range-token work must remain bounded before output allocation"
);
let too_many_bytes = "1".repeat(MAX_PAGE_RANGES_BYTES + 1);
assert!(
parse_page_ranges(&too_many_bytes).is_err(),
"range input bytes must be bounded before numeric parsing"
);
}
#[test]
@@ -317,6 +470,8 @@ mod tests {
streamed["obscuraCapabilities"]["honorsPrintBackground"],
true
);
assert_eq!(streamed["obscuraCapabilities"]["honorsScale"], true);
assert_eq!(streamed["obscuraCapabilities"]["pageRanges"], true);
assert_eq!(streamed["obscuraTaggedPDF"], false);
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
assert_eq!(
@@ -353,5 +508,25 @@ mod tests {
.is_err(),
"closed PDF handles must release their buffer"
);
let ranged = print_to_pdf(
&json!({
"paperWidth": 4.0, "paperHeight": 6.0,
"marginTop": 0.5, "marginBottom": 0.5,
"marginLeft": 0.5, "marginRight": 0.5,
"scale": 1,
"pageRanges": "2",
}),
&mut ctx,
&session,
)
.await
.expect("single selected page");
let ranged_bytes = base64::engine::general_purpose::STANDARD
.decode(ranged["data"].as_str().unwrap())
.unwrap();
let ranged_text = String::from_utf8_lossy(&ranged_bytes);
assert!(ranged_text.contains("/Count 1"));
assert_eq!(ranged_text.matches("/Subtype /Image").count(), 1);
}
}
+394 -61
View File
@@ -90,7 +90,14 @@ const _DOM_MUTATION_COMMANDS = new Set([
]);
const _dom = (cmd, a1, a2) => {
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
if (_DOM_MUTATION_COMMANDS.has(cmd)) _domMutationEpoch++;
if (_DOM_MUTATION_COMMANDS.has(cmd)) {
_domMutationEpoch++;
// Resize observation is tied to rendering-invalidating DOM work. The
// hook is installed later in bootstrap, before page script can run.
if (typeof globalThis.__obscura_recompute_resizes === "function") {
globalThis.__obscura_recompute_resizes();
}
}
return result;
};
@@ -3240,6 +3247,7 @@ class Element extends Node {
if (changed && !this._scrollSuppress) this._fireScroll();
if (changed &&
typeof globalThis.__obscura_recompute_intersections === "function") {
// Scrolling changes target positions, not ResizeObserver box sizes.
globalThis.__obscura_recompute_intersections();
}
}
@@ -4634,6 +4642,13 @@ class HTMLImageElement extends Element {
_applyImageMetadata(metadata, request, dispatchEvent) {
if (request !== this._imageRequest) return;
const previousLifecycle = [
this._imageComplete,
this._imageDecoded,
this._imageCurrentSrc,
this._imageNaturalWidth,
this._imageNaturalHeight,
];
const selected = metadata && metadata.currentSrc
? String(metadata.currentSrc)
: "";
@@ -4666,6 +4681,18 @@ class HTMLImageElement extends Element {
try { this.dispatchEvent(new Event("error")); } catch (_error) {}
}
}
const lifecycleChanged =
previousLifecycle[0] !== this._imageComplete ||
previousLifecycle[1] !== this._imageDecoded ||
previousLifecycle[2] !== this._imageCurrentSrc ||
previousLifecycle[3] !== this._imageNaturalWidth ||
previousLifecycle[4] !== this._imageNaturalHeight;
if (lifecycleChanged) {
// Intrinsic dimensions can become layout input at request completion
// even though no DOM attribute changed. Stable cache-only getters must
// not manufacture rendering updates on every read.
_scheduleResizeRenderCheckpoint();
}
}
_resolveImageDecodes(request) {
@@ -5959,43 +5986,280 @@ if (!('isConnected' in Node.prototype)) {
});
}
// Resize observation is part of the rendering update, not a timer. Keep the
// last delivered size for each observed box and perform one coalesced geometry
// checkpoint after DOM/viewport work. This follows the browser lifecycle and,
// importantly, does not keep the event loop alive with speculative re-fires.
globalThis.__resizeObservers = [];
let _resizeRenderCheckpointPending = false;
let _resizeRenderCheckpointRunning = false;
let _resizeRenderCheckpointRerun = false;
function _registerResizeObserver(observer) {
if (!globalThis.__resizeObservers.includes(observer)) {
globalThis.__resizeObservers.push(observer);
}
}
function _unregisterResizeObserver(observer) {
const index = globalThis.__resizeObservers.indexOf(observer);
if (index >= 0) globalThis.__resizeObservers.splice(index, 1);
}
function _scheduleResizeRenderCheckpoint() {
if (!globalThis.__resizeObservers.length) return;
if (_resizeRenderCheckpointRunning) {
_resizeRenderCheckpointRerun = true;
return;
}
if (_resizeRenderCheckpointPending) return;
_resizeRenderCheckpointPending = true;
_scheduleAfter(0, () => {
_resizeRenderCheckpointPending = false;
_resizeRenderCheckpointRunning = true;
let depth = 0;
let skipped = false;
// Depth strictly increases after each broadcast, so this is naturally
// bounded by tree depth. Keep a hard ceiling for adversarial callbacks
// that manufacture an ever-deeper subtree during one delivery cycle.
for (let iteration = 0; iteration < 64; iteration++) {
_resizeRenderCheckpointRerun = false;
const measurements = new Map();
let shallowest = Infinity;
let active = false;
skipped = false;
const observers = [...globalThis.__resizeObservers];
// Gather every observer before invoking any callback. A callback from an
// earlier observer must not change the geometry gathered for a later one.
for (const observer of observers) {
const gathered = observer._gather(measurements, depth);
active = active || gathered.active;
skipped = skipped || gathered.skipped;
shallowest = Math.min(shallowest, gathered.shallowest);
}
if (!active) break;
for (const observer of observers) observer._broadcast();
depth = shallowest;
if (!_resizeRenderCheckpointRerun) break;
if (iteration === 63) skipped = true;
}
_resizeRenderCheckpointRunning = false;
_resizeRenderCheckpointRerun = false;
if (skipped) {
// Match the standardized loop-limit signal without queuing another
// internal task that could keep a pathological page permanently busy.
try {
globalThis.dispatchEvent(new ErrorEvent("error", {
message: "ResizeObserver loop completed with undelivered notifications."
}));
} catch (_error) {}
}
});
}
globalThis.__obscura_recompute_resizes = _scheduleResizeRenderCheckpoint;
function _roNumber(value) {
const number = Number.parseFloat(value);
return Number.isFinite(number) ? number : 0;
}
function _roPhysicalSize(inlineSize, blockSize, vertical) {
return vertical
? new ResizeObserverSize(_roConstructionKey, blockSize, inlineSize)
: new ResizeObserverSize(_roConstructionKey, inlineSize, blockSize);
}
function _roNodeDepth(target) {
let depth = 1;
let node = target;
while (node && (node = node.parentNode || node.host || null)) depth++;
return depth;
}
function _roMeasurement(target) {
let geometry = null;
const hasRenderer = typeof Deno.core.ops.op_layout_geometry === "function";
if (hasRenderer && target?._nid != null) {
try {
const raw = Deno.core.ops.op_layout_geometry(String(target._nid | 0));
geometry = raw ? JSON.parse(raw) : null;
} catch (_error) {}
}
// Preserve deterministic geometry in non-render builds. This path has no
// native layout cache, but lifecycle behavior (initial delivery and
// change-only rechecks) should remain useful to automation consumers.
if (!hasRenderer && target?.getBoundingClientRect) {
const rect = target.getBoundingClientRect();
geometry = {
x: rect.x, y: rect.y,
clientWidth: rect.width, clientHeight: rect.height,
};
}
// No renderer box (detached, display:none) has zero sizes. The initial zero
// is still delivered because an observation starts without a reported size.
if (!geometry) {
const zero = _roPhysicalSize(0, 0, false);
return {
contentRect: _ioRect(0, 0, 0, 0),
contentBoxSize: [zero],
borderBoxSize: [_roPhysicalSize(0, 0, false)],
devicePixelContentBoxSize: [_roPhysicalSize(0, 0, false)],
selected: { "content-box": [0, 0], "border-box": [0, 0], "device-pixel-content-box": [0, 0] },
};
}
const style = getComputedStyle(target);
const paddingTop = _roNumber(style.paddingTop);
const paddingRight = _roNumber(style.paddingRight);
const paddingBottom = _roNumber(style.paddingBottom);
const paddingLeft = _roNumber(style.paddingLeft);
const borderTop = _roNumber(style.borderTopWidth);
const borderRight = _roNumber(style.borderRightWidth);
const borderBottom = _roNumber(style.borderBottomWidth);
const borderLeft = _roNumber(style.borderLeftWidth);
const clientWidth = Math.max(0, Number(geometry.clientWidth) || 0);
const clientHeight = Math.max(0, Number(geometry.clientHeight) || 0);
const contentWidth = Math.max(0, clientWidth - paddingLeft - paddingRight);
const contentHeight = Math.max(0, clientHeight - paddingTop - paddingBottom);
const borderWidth = Math.max(0, clientWidth + borderLeft + borderRight);
const borderHeight = Math.max(0, clientHeight + borderTop + borderBottom);
const vertical = /^(?:vertical|sideways)/.test(style.writingMode || "");
// Per Resize Observer, ordinary non-replaced inline elements have an empty
// observed box even though getBoundingClientRect() encloses their glyphs.
const replaced = /^(?:IMG|VIDEO|AUDIO|IFRAME|EMBED|OBJECT|INPUT|TEXTAREA|SELECT|CANVAS|SVG)$/.test(
target.tagName || ""
);
const emptyInline = style.display === "inline" && !replaced;
const observedContentWidth = emptyInline ? 0 : contentWidth;
const observedContentHeight = emptyInline ? 0 : contentHeight;
const observedBorderWidth = emptyInline ? 0 : borderWidth;
const observedBorderHeight = emptyInline ? 0 : borderHeight;
const contentSize = _roPhysicalSize(observedContentWidth, observedContentHeight, vertical);
const borderSize = _roPhysicalSize(observedBorderWidth, observedBorderHeight, vertical);
// Device-pixel content sizes snap the content edges, rather than merely
// rounding a CSS size multiplied by DPR. Preserve that distinction for
// fractional positions and dimensions.
const dpr = Math.max(0, Number(globalThis.devicePixelRatio) || 1);
const contentLeft = (Number(geometry.x) + borderLeft + paddingLeft) * dpr;
const contentTop = (Number(geometry.y) + borderTop + paddingTop) * dpr;
const deviceWidth = emptyInline ? 0 : Math.max(0,
Math.round(contentLeft + contentWidth * dpr) - Math.round(contentLeft));
const deviceHeight = emptyInline ? 0 : Math.max(0,
Math.round(contentTop + contentHeight * dpr) - Math.round(contentTop));
const deviceSize = _roPhysicalSize(deviceWidth, deviceHeight, vertical);
return {
contentRect: emptyInline
? _ioRect(0, 0, 0, 0)
: _ioRect(paddingLeft, paddingTop, contentWidth, contentHeight),
contentBoxSize: [contentSize],
borderBoxSize: [borderSize],
devicePixelContentBoxSize: [deviceSize],
selected: {
"content-box": [contentSize.inlineSize, contentSize.blockSize],
"border-box": [borderSize.inlineSize, borderSize.blockSize],
"device-pixel-content-box": [deviceSize.inlineSize, deviceSize.blockSize],
},
};
}
const _roConstructionKey = {};
const _roSizeValues = new WeakMap();
globalThis.ResizeObserverSize = class ResizeObserverSize {
constructor(key, inlineSize, blockSize) {
if (key !== _roConstructionKey) throw new TypeError("Illegal constructor");
_roSizeValues.set(this, { inlineSize, blockSize });
}
get inlineSize() { return _roSizeValues.get(this)?.inlineSize; }
get blockSize() { return _roSizeValues.get(this)?.blockSize; }
};
const _roEntryValues = new WeakMap();
globalThis.ResizeObserverEntry = class ResizeObserverEntry {
constructor(key, target, measurement) {
if (key !== _roConstructionKey) throw new TypeError("Illegal constructor");
_roEntryValues.set(this, { target, measurement });
}
get target() { return _roEntryValues.get(this)?.target; }
get contentRect() { return _roEntryValues.get(this)?.measurement.contentRect; }
get borderBoxSize() { return _roEntryValues.get(this)?.measurement.borderBoxSize; }
get contentBoxSize() { return _roEntryValues.get(this)?.measurement.contentBoxSize; }
get devicePixelContentBoxSize() {
return _roEntryValues.get(this)?.measurement.devicePixelContentBoxSize;
}
};
globalThis.ResizeObserver = class ResizeObserver {
constructor(callback) {
if (typeof callback !== "function") {
throw new TypeError("ResizeObserver callback must be a function");
}
this._callback = callback;
this._targets = new Set();
this._connected = true;
this._fireCount = 0;
this._targets = new Map();
this._active = [];
this._skipped = false;
}
_fireFor(targets) {
if (!this._connected || !targets.length) return;
const records = targets.map(target => {
const r = target.getBoundingClientRect ? target.getBoundingClientRect() : { x: 0, y: 0, width: 100, height: 20 };
return {
target,
contentRect: { x: r.x || 0, y: r.y || 0, width: r.width || 100, height: r.height || 20, top: r.top || 0, left: r.left || 0, bottom: r.bottom || 20, right: r.right || 100 },
borderBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
contentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
devicePixelContentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
};
_gather(measurements, depth) {
this._active = [];
this._skipped = false;
let shallowest = Infinity;
for (const [target, observation] of this._targets) {
let measurement = measurements.get(target);
if (!measurement) {
measurement = _roMeasurement(target);
measurements.set(target, measurement);
}
const size = measurement.selected[observation.box];
const last = observation.last;
if (last && last[0] === size[0] && last[1] === size[1]) continue;
const targetDepth = _roNodeDepth(target);
// A callback may disconnect and begin observing a different target.
// Browsers deliver that initial observation on the next rendering
// opportunity. We fold that opportunity into this bounded cycle so it
// does not require a persistent frame timer; already-reported targets
// still obey the loop-depth guard.
if (targetDepth <= depth && last) {
this._skipped = true;
continue;
}
shallowest = Math.min(shallowest, targetDepth);
this._active.push({ target, observation, measurement, size });
}
return {
active: this._active.length > 0,
skipped: this._skipped,
shallowest,
};
}
_broadcast() {
if (!this._active.length) return;
const entries = this._active.map(({ target, observation, measurement, size }) => {
// Update before invoking callbacks. Callback-driven mutations are
// compared against this delivery in the same bounded delivery cycle.
observation.last = size.slice();
return new ResizeObserverEntry(_roConstructionKey, target, measurement);
});
try { this._callback(records, this); } catch (e) { /* RO callbacks must not propagate */ }
this._active = [];
try { this._callback(entries, this); } catch (_error) {}
}
observe(el) {
if (!el || !this._connected) return;
if (this._targets.has(el)) return;
this._targets.add(el);
Promise.resolve().then(() => this._fireFor([el]));
[200, 800].forEach(delay => {
setTimeout(() => {
if (this._connected && this._targets.has(el) && this._fireCount < 16) {
this._fireCount++;
this._fireFor([el]);
}
}, delay);
});
observe(target, options = {}) {
if (!(target instanceof Element)) {
throw new TypeError("ResizeObserver.observe requires an Element");
}
const box = options && options.box != null ? String(options.box) : "content-box";
if (box !== "content-box" && box !== "border-box" &&
box !== "device-pixel-content-box") {
throw new TypeError(`Invalid ResizeObserver box option: ${box}`);
}
const current = this._targets.get(target);
if (current && current.box === box) return;
this._targets.set(target, { box, last: null });
_registerResizeObserver(this);
_scheduleResizeRenderCheckpoint();
}
unobserve(target) {
this._targets.delete(target);
if (!this._targets.size) _unregisterResizeObserver(this);
}
disconnect() {
this._targets.clear();
this._active = [];
this._skipped = false;
_unregisterResizeObserver(this);
}
unobserve(el) { this._targets.delete(el); }
disconnect() { this._connected = false; this._targets.clear(); }
};
if (typeof TextEncoder === 'undefined') {
@@ -6786,12 +7050,19 @@ function _ioMargins(value) {
if (parsed.length === 3) return [parsed[0], parsed[1], parsed[2], parsed[1]];
return parsed;
}
function _ioUnsupportedRoot() {
const error = new Error(
"Obscura IntersectionObserver element roots require element scroll-container layout"
);
error.name = "NotSupportedError";
return error;
function _ioClipsOverflow(value) {
return /^(?:auto|clip|hidden|overlay|scroll)$/.test(String(value || ""));
}
function _ioElementPaddingBox(element, style) {
const geometry = element._renderBoxGeometry();
const rect = geometry
? element._rectFromRenderGeometry(geometry)
: element.getBoundingClientRect();
const borderLeft = _roNumber(style.borderLeftWidth);
const borderTop = _roNumber(style.borderTopWidth);
const width = geometry ? geometry.clientWidth : element.clientWidth;
const height = geometry ? geometry.clientHeight : element.clientHeight;
return _ioRect(rect.left + borderLeft, rect.top + borderTop, width, height);
}
globalThis.IntersectionObserver = class IntersectionObserver {
constructor(callback, options) {
@@ -6800,7 +7071,11 @@ globalThis.IntersectionObserver = class IntersectionObserver {
}
this._callback = callback;
this._options = options || {};
if (this._options.root != null) throw _ioUnsupportedRoot();
this._root = this._options.root == null ? null : this._options.root;
if (this._root !== null && !(this._root instanceof Element) &&
this._root?.nodeType !== 9) {
throw new TypeError("IntersectionObserver root must be an Element or Document");
}
this._margins = _ioMargins(this._options.rootMargin || "0px");
if (!this._margins) throw new SyntaxError("Invalid IntersectionObserver rootMargin");
const raw = this._options.threshold == null
@@ -6819,8 +7094,29 @@ globalThis.IntersectionObserver = class IntersectionObserver {
globalThis.__intersectionObservers.push(this);
}
_rootBounds() {
const width = globalThis.innerWidth || 1280;
const height = globalThis.innerHeight || 720;
let x = 0, y = 0;
let width = globalThis.innerWidth || 1280;
let height = globalThis.innerHeight || 720;
if (this._root instanceof Element) {
const style = getComputedStyle(this._root);
const clips = _ioClipsOverflow(style.overflowX) ||
_ioClipsOverflow(style.overflowY);
if (clips) {
const paddingBox = _ioElementPaddingBox(this._root, style);
x = paddingBox.left;
y = paddingBox.top;
// The intersection root for a content-clipping element is its padding
// box (the CSSOM client box), independent of its current scroll offset.
width = paddingBox.width;
height = paddingBox.height;
} else {
const rect = this._root.getBoundingClientRect();
x = rect.left;
y = rect.top;
width = rect.width;
height = rect.height;
}
}
const resolve = (margin, basis) =>
margin.unit === "%" ? margin.value * basis / 100 : margin.value;
// IntersectionObserver resolves every rootMargin percentage against the
@@ -6829,27 +7125,55 @@ globalThis.IntersectionObserver = class IntersectionObserver {
const right = resolve(this._margins[1], width);
const bottom = resolve(this._margins[2], width);
const left = resolve(this._margins[3], width);
return _ioRect(-left, -top, width + left + right, height + top + bottom);
return _ioRect(x - left, y - top, width + left + right, height + top + bottom);
}
_entry(target) {
_entry(target, root) {
const rect = target.getBoundingClientRect();
const root = this._rootBounds();
const left = Math.max(rect.left, root.left);
const top = Math.max(rect.top, root.top);
const right = Math.min(rect.right, root.right);
const bottom = Math.min(rect.bottom, root.bottom);
const edgesTouch = right >= left && bottom >= top;
let inRootTree = !(this._root instanceof Element) || this._root.contains(target);
let left = Math.max(rect.left, root.left);
let top = Math.max(rect.top, root.top);
let right = Math.min(rect.right, root.right);
let bottom = Math.min(rect.bottom, root.bottom);
// Mapping a target to its intersection root clips it at every intervening
// overflow container. Intersecting only with the final root incorrectly
// exposes offscreen children of nested carousels, virtual lists, and lazy
// loading viewports. Use each ancestor's padding box, independently by
// axis, matching Chromium's rectangular overflow clip chain.
let ancestor = target.parentNode || target.host || null;
while (inRootTree && ancestor && ancestor !== this._root && ancestor.nodeType !== 9) {
if (ancestor instanceof Element) {
const style = getComputedStyle(ancestor);
const clipX = _ioClipsOverflow(style.overflowX);
const clipY = _ioClipsOverflow(style.overflowY);
if (clipX || clipY) {
const clip = _ioElementPaddingBox(ancestor, style);
if (clipX) {
left = Math.max(left, clip.left);
right = Math.min(right, clip.right);
}
if (clipY) {
top = Math.max(top, clip.top);
bottom = Math.min(bottom, clip.bottom);
}
}
}
ancestor = ancestor.parentNode || ancestor.host || null;
}
if (this._root instanceof Element && ancestor !== this._root) inRootTree = false;
const edgesTouch = inRootTree && right >= left && bottom >= top;
const width = Math.max(0, right - left);
const height = Math.max(0, bottom - top);
const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height);
const area = width * height;
const isIntersecting = edgesTouch;
const area = isIntersecting ? width * height : 0;
return {
target,
isIntersecting,
intersectionRatio: targetArea > 0 ? area / targetArea : (isIntersecting ? 1 : 0),
boundingClientRect: _ioRect(rect.x, rect.y, rect.width, rect.height),
intersectionRect: _ioRect(left, top, width, height),
intersectionRect: isIntersecting ? _ioRect(left, top, width, height) : _ioRect(0, 0, 0, 0),
rootBounds: root,
time: performance.now(),
};
@@ -6859,8 +7183,8 @@ globalThis.IntersectionObserver = class IntersectionObserver {
while (index < this._thresholds.length && this._thresholds[index] <= ratio) index++;
return index;
}
_queueChanged(target, forceInitial) {
const entry = this._entry(target);
_queueChanged(target, forceInitial, root) {
const entry = this._entry(target, root);
const previous = this._previous.get(target);
const changed = forceInitial || !previous ||
previous.isIntersecting !== entry.isIntersecting ||
@@ -6874,8 +7198,9 @@ globalThis.IntersectionObserver = class IntersectionObserver {
}
_check(targets, forceInitial) {
if (!this._connected) return;
const root = this._rootBounds();
for (const target of targets) {
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial);
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial, root);
}
if (!this._records.length || this._deliveryPending) return;
this._deliveryPending = true;
@@ -6915,23 +7240,30 @@ globalThis.IntersectionObserver = class IntersectionObserver {
if (index >= 0) globalThis.__intersectionObservers.splice(index, 1);
}
takeRecords() { return this._records.splice(0); }
get root() { return null; }
get root() { return this._root; }
get rootMargin() {
return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" ");
}
get thresholds() { return this._thresholds.slice(); }
};
(function() {
const recompute = () => {
const renderingUpdate = () => {
_scheduleIntersectionRenderCheckpoint();
_scheduleResizeRenderCheckpoint();
};
globalThis.__obscura_recompute_intersections = recompute;
globalThis.addEventListener("resize", recompute);
// Scrolling calls the IO-only hook. Actual viewport resizing remains a full
// rendering update and schedules both observer families.
globalThis.__obscura_recompute_intersections = _scheduleIntersectionRenderCheckpoint;
globalThis.addEventListener("resize", renderingUpdate);
const wireUp = () => {
if (!globalThis.document?.body) return;
const observer = new MutationObserver(recompute);
if (!globalThis.document) return;
// DOM writes synchronously mark ResizeObserver dirty through `_dom`; this
// MutationObserver is only needed for intersection geometry. Scheduling RO
// again here would escape its depth-bounded delivery cycle and allow a
// self-resizing callback to create an infinite chain of zero-delay tasks.
const observer = new MutationObserver(_scheduleIntersectionRenderCheckpoint);
try {
observer.observe(globalThis.document.body, {
observer.observe(globalThis.document, {
childList: true,
subtree: true,
attributes: true,
@@ -6939,7 +7271,7 @@ globalThis.IntersectionObserver = class IntersectionObserver {
});
} catch {}
};
if (globalThis.document?.body) wireUp();
if (globalThis.document) wireUp();
else Promise.resolve().then(wireUp);
})();
globalThis.IntersectionObserverEntry = class IntersectionObserverEntry {};
@@ -11491,6 +11823,7 @@ if (typeof FontFace === 'undefined') {
unicodeRange: face.unicodeRange
});
Deno.core.ops.op_set_dynamic_fonts(JSON.stringify(registrations.filter(face => !face.skip)));
_scheduleResizeRenderCheckpoint();
}
_faceChanged(face) {
this._syncNative();
+23
View File
@@ -3271,6 +3271,20 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
}
let base_url = document_base_url(&gs);
let viewport = gs.viewport;
let previous_dimensions = (!cached_only)
.then(|| {
gs.dom.as_ref().and_then(|dom| {
gs.render_resources
.cached_image_element_metadata(
dom,
node_id,
viewport,
base_url.as_deref(),
)
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
})
})
.flatten();
let ObscuraState {
dom,
render_resources,
@@ -3317,6 +3331,15 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
})
.to_string();
}
// The page transport invalidates prepared layout when it seeds successful
// bytes. Cover the synchronous compatibility-loader path too, but only
// when this call actually discovers a new decodable intrinsic size. A
// cache hit or retained decode failure cannot change used geometry.
if !cached_only && dimensions.is_some() && dimensions != previous_dimensions {
gs.prepared_render = None;
gs.pending_style_mutations.clear();
gs.resolved_scroll = None;
}
match dimensions {
Some((width, height)) => serde_json::json!({
"state": "loaded",
+612 -5
View File
@@ -412,6 +412,9 @@ impl ObscuraJsRuntime {
}}\
if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
globalThis.__obscura_recompute_intersections();\
}}\
if(typeof globalThis.__obscura_recompute_resizes==='function'){{\
globalThis.__obscura_recompute_resizes();\
}}",
),
);
@@ -6444,6 +6447,310 @@ mod tests {
assert_eq!(values[2], values[3]);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_reports_real_boxes_only_when_selected_size_changes() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="target" style="box-sizing:border-box;width:120px;height:80px;
padding:5px 7px;border:2px solid black"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(300.0, 200.0);
rt.run_page_init();
rt.execute_script(
"resize-observer-boxes",
r#"
globalThis.__resizeRecords = [];
globalThis.__resizeObserver = new ResizeObserver(entries => {
__resizeRecords.push(...entries.map(entry => ({
interfaces: [
entry instanceof ResizeObserverEntry,
entry.contentBoxSize[0] instanceof ResizeObserverSize,
entry.borderBoxSize[0] instanceof ResizeObserverSize,
entry.devicePixelContentBoxSize[0] instanceof ResizeObserverSize,
],
contentRect: [
entry.contentRect.x, entry.contentRect.y,
entry.contentRect.width, entry.contentRect.height,
],
content: [
entry.contentBoxSize[0].inlineSize,
entry.contentBoxSize[0].blockSize,
],
border: [
entry.borderBoxSize[0].inlineSize,
entry.borderBoxSize[0].blockSize,
],
device: [
entry.devicePixelContentBoxSize[0].inlineSize,
entry.devicePixelContentBoxSize[0].blockSize,
],
})));
});
__resizeObserver.observe(document.getElementById("target"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("__resizeRecords").unwrap(),
serde_json::json!([{
"interfaces": [true, true, true, true],
"contentRect": [7, 5, 102, 66],
"content": [102, 66],
"border": [120, 80],
"device": [102, 66],
}])
);
// A style mutation still causes a rendering checkpoint, but unchanged
// observed geometry must not produce a speculative notification.
rt.evaluate(r#"document.getElementById("target").style.color = "red""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("__resizeRecords.length").unwrap().as_f64(),
Some(1.0)
);
rt.evaluate(r#"document.getElementById("target").style.width = "140px""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate(
"__resizeRecords.map(record => [record.content[0], record.border[0]])"
)
.unwrap(),
serde_json::json!([[102, 120], [122, 140]])
);
assert_eq!(
rt.evaluate("__obscura_nextPendingTimeoutDelay()")
.unwrap()
.as_f64(),
Some(-1.0)
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_selected_box_and_viewport_lifecycle_match_chromium() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="target" style="box-sizing:border-box;width:50vw;height:40px;
padding:4px;border:2px solid"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"resize-observer-selected-box",
r#"
globalThis.__contentWidths = [];
globalThis.__borderWidths = [];
const target = document.getElementById("target");
globalThis.__contentObserver = new ResizeObserver(entries => {
__contentWidths.push(entries[0].contentBoxSize[0].inlineSize);
});
globalThis.__borderObserver = new ResizeObserver(entries => {
__borderWidths.push(entries[0].borderBoxSize[0].inlineSize);
});
__contentObserver.observe(target, { box: "content-box" });
__borderObserver.observe(target, { box: "border-box" });
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
serde_json::json!([[88], [100]])
);
// A viewport update is a rendering update even without a DOM mutation.
rt.set_viewport(300.0, 100.0);
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
serde_json::json!([[88, 138], [100, 150]])
);
// With border-box sizing a thicker border shrinks the content box but
// leaves the selected border box unchanged.
rt.evaluate(r#"document.getElementById("target").style.borderWidth = "4px""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__contentWidths, __borderWidths]").unwrap(),
serde_json::json!([[88, 138, 134], [100, 150]])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn scrolling_does_not_remeasure_resize_observer_targets() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0;height:1000px">
<div id="probe" style="width:40px;height:20px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"observe-before-scroll",
r#"
globalThis.__scrollResizeRecords = 0;
globalThis.__scrollResizeObserver = new ResizeObserver(entries => {
__scrollResizeRecords += entries.length;
});
__scrollResizeObserver.observe(document.getElementById("probe"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("__scrollResizeRecords").unwrap().as_f64(),
Some(1.0)
);
rt.execute_script(
"count-scroll-geometry-reads",
r#"
globalThis.__scrollGeometryReads = 0;
globalThis.__nativeLayoutGeometry = Deno.core.ops.op_layout_geometry;
Deno.core.ops.op_layout_geometry = (...args) => {
__scrollGeometryReads++;
return __nativeLayoutGeometry(...args);
};
window.scrollTo(0, 50);
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
let result = rt
.evaluate("[scrollY, __scrollGeometryReads, __scrollResizeRecords]")
.unwrap();
rt.execute_script(
"restore-layout-geometry-op",
"Deno.core.ops.op_layout_geometry = __nativeLayoutGeometry;",
)
.unwrap();
assert_eq!(result, serde_json::json!([50, 0, 1]));
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_disconnect_is_reusable_and_inline_boxes_are_empty() {
let dom = parse_html(
r#"<html><body><div id="first" style="width:40px;height:20px"></div>
<span id="inline" style="padding:8px;border:2px solid">text</span>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const deliveries = [];
const observer = new ResizeObserver(entries => {
deliveries.push(entries.map(entry => [
entry.target.id,
entry.contentRect.width,
entry.borderBoxSize[0].inlineSize,
]));
if (deliveries.length === 1) {
observer.disconnect();
observer.observe(document.getElementById("inline"));
} else {
observer.disconnect();
resolve([deliveries, __resizeObservers.length]);
}
});
observer.observe(document.getElementById("first"));
setTimeout(() => resolve(["timed out"]), 100);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!([[[["first", 40, 40]], [["inline", 0, 0]]], 0])
);
assert_eq!(
rt.evaluate(
r#"[
(() => { try { new ResizeObserver(null); } catch (e) { return e.name; } })(),
(() => { try { new ResizeObserver(() => {}).observe(document); } catch (e) { return e.name; } })(),
(() => { try { new ResizeObserver(() => {}).observe(document.body, {box:"margin-box"}); } catch (e) { return e.name; } })(),
]"#,
)
.unwrap(),
serde_json::json!(["TypeError", "TypeError", "TypeError"])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn resize_observer_self_resize_is_depth_bounded_without_timer_spin() {
let dom = parse_html(
r#"<html><body><div id="target" style="width:40px;height:20px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"resize-observer-loop-limit",
r#"
globalThis.__resizeCallbacks = 0;
globalThis.__resizeLoopErrors = 0;
addEventListener("error", event => {
if (event.message === "ResizeObserver loop completed with undelivered notifications.") {
__resizeLoopErrors++;
}
});
const target = document.getElementById("target");
globalThis.__loopingResizeObserver = new ResizeObserver(() => {
__resizeCallbacks++;
target.style.width = (40 + __resizeCallbacks) + "px";
});
__loopingResizeObserver.observe(target);
"#,
)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate(
"[__resizeCallbacks, __resizeLoopErrors, __obscura_nextPendingTimeoutDelay()]"
)
.unwrap(),
serde_json::json!([1, 1, -1])
);
// A later external rendering change starts a fresh bounded cycle; the
// suppressed same-depth observation did not poison future delivery.
rt.evaluate(r#"document.getElementById("target").style.width = "60px""#)
.unwrap();
rt.run_event_loop_bounded(40).await.unwrap();
assert_eq!(
rt.evaluate("[__resizeCallbacks, __resizeLoopErrors]").unwrap(),
serde_json::json!([2, 2])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_tracks_viewport_threshold_crossings() {
@@ -6492,6 +6799,134 @@ mod tests {
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_element_root_uses_live_padding_box_and_scroll() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="root" style="position:absolute;left:10px;top:20px;width:100px;
height:80px;padding:10px;border:5px solid;overflow:auto">
<div style="height:100px"></div>
<div id="target" style="height:20px"></div>
</div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(300.0, 200.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const records = [];
const root = document.getElementById("root");
const observer = new IntersectionObserver(entries => {
records.push(...entries.map(entry => ({
intersecting: entry.isIntersecting,
ratio: entry.intersectionRatio,
root: [
entry.rootBounds.x, entry.rootBounds.y,
entry.rootBounds.width, entry.rootBounds.height,
],
intersection: [
entry.intersectionRect.x, entry.intersectionRect.y,
entry.intersectionRect.width, entry.intersectionRect.height,
],
})));
}, { root, threshold: [0, 1] });
observer.observe(document.getElementById("target"));
setTimeout(() => { root.scrollTop = 999; }, 5);
setTimeout(() => resolve(records), 30);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!([
{
"intersecting": false,
"ratio": 0,
"root": [15, 25, 120, 100],
"intersection": [0, 0, 0, 0],
},
{
"intersecting": true,
"ratio": 1,
"root": [15, 25, 120, 100],
"intersection": [25, 95, 100, 20],
},
])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_clips_through_intermediate_overflow_ancestors() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="root" style="position:absolute;left:10px;top:20px;
width:300px;height:300px;overflow:visible">
<div id="clip" style="width:100px;height:100px;overflow:hidden">
<div style="height:150px"></div>
<div id="target" style="height:20px"></div>
</div>
</div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(400.0, 400.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const records = [];
const clip = document.getElementById("clip");
const observer = new IntersectionObserver(entries => {
records.push(...entries.map(entry => [
entry.isIntersecting,
entry.intersectionRatio,
[
entry.intersectionRect.x,
entry.intersectionRect.y,
entry.intersectionRect.width,
entry.intersectionRect.height,
],
]));
}, {
root: document.getElementById("root"),
threshold: [0, 1],
});
observer.observe(document.getElementById("target"));
setTimeout(() => { clip.scrollTop = 999; }, 5);
setTimeout(() => resolve(records), 30);
})
"#,
true,
true,
)
.await
.unwrap();
// Chromium reports the initial target as non-intersecting: although it
// lies inside the explicit root, the intermediate overflow container
// clips it. Programmatic scrolling then reveals the complete box.
assert_eq!(
result.value.unwrap(),
serde_json::json!([
[false, 0, [0, 0, 0, 0]],
[true, 1, [10, 100, 100, 20]],
])
);
}
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_initial_geometry_waits_for_one_render_checkpoint() {
let mut rt = setup_runtime(
@@ -6571,16 +7006,17 @@ mod tests {
);
marginObserver.observe(document.getElementById("margin-target"));
zeroObserver.observe(document.getElementById("zero"));
let unsupported = "";
let elementRoot = false;
try {
new IntersectionObserver(() => {}, {
const rooted = new IntersectionObserver(() => {}, {
root: document.getElementById("root")
});
elementRoot = rooted.root === document.getElementById("root");
} catch (error) {
unsupported = error.name;
elementRoot = error.name;
}
setTimeout(() => resolve([
marginRecords, zeroRecords, unsupported,
marginRecords, zeroRecords, elementRoot,
marginObserver.rootMargin, marginObserver.thresholds,
]), 200);
})
@@ -6595,7 +7031,7 @@ mod tests {
serde_json::json!([
[[true, 1, 120]],
[[true, 1]],
"NotSupportedError",
true,
"0px 0px 20px 0px",
[0, 1],
])
@@ -7776,6 +8212,177 @@ mod tests {
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn parser_image_fallback_invalidates_only_new_intrinsic_geometry() {
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let loader_calls = calls.clone();
let png = two_by_three_png();
let mut rt = parser_image_runtime(
r#"<img id="late" src="late.png">"#,
move |_url: &str| {
loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(png.clone())
},
);
{
let mut state = rt.state.borrow_mut();
let previous = state.render_resources.set_sync_loading_enabled(false);
assert!(ensure_prepared_render(&mut state).is_some());
state
.render_resources
.set_sync_loading_enabled(previous);
assert!(state.prepared_render.is_some());
}
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
rt.execute_script(
"load-image-after-layout",
r#"
globalThis.__lateEvents = [];
const late = document.getElementById("late");
late.addEventListener("load", () => __lateEvents.push("load"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate(
"[late.complete, late.naturalWidth, late.naturalHeight, __lateEvents]"
)
.unwrap(),
serde_json::json!([true, 2, 3, ["load"]])
);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(rt.state.borrow().prepared_render.is_none());
// Once the successful dimensions are retained, another loading-form
// metadata probe is only a cache hit and must preserve fresh layout.
{
let mut state = rt.state.borrow_mut();
assert!(ensure_prepared_render(&mut state).is_some());
assert!(state.prepared_render.is_some());
}
rt.execute_script(
"reload-retained-image",
r#"late.src = "late.png";"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(rt.state.borrow().prepared_render.is_some());
let missing_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let missing_loader_calls = missing_calls.clone();
let mut missing = parser_image_runtime(
r#"<img id="missing" src="missing.png">"#,
move |_url: &str| {
missing_loader_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
None
},
);
{
let mut state = missing.state.borrow_mut();
let previous = state.render_resources.set_sync_loading_enabled(false);
assert!(ensure_prepared_render(&mut state).is_some());
state
.render_resources
.set_sync_loading_enabled(previous);
assert!(state.prepared_render.is_some());
}
missing
.execute_script(
"fail-image-after-layout",
r#"
globalThis.__missingEvents = [];
const missing = document.getElementById("missing");
missing.addEventListener("error", () => __missingEvents.push("error"));
"#,
)
.unwrap();
missing.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
missing
.evaluate(
"[missing.complete, missing.naturalWidth, missing.naturalHeight, __missingEvents]"
)
.unwrap(),
serde_json::json!([true, 0, 0, ["error"]])
);
assert_eq!(
missing_calls.load(std::sync::atomic::Ordering::SeqCst),
1
);
assert!(missing.state.borrow().prepared_render.is_some());
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn stable_cached_image_getters_do_not_queue_resize_geometry_work() {
let png = two_by_three_png();
let mut rt = parser_image_runtime(
r#"<img id="cached" src="cached.png">
<div id="probe" style="width:40px;height:20px"></div>"#,
move |_url: &str| Some(png.clone()),
);
rt.execute_script(
"settle-cached-image",
r#"
const cached = document.getElementById("cached");
void cached.complete;
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate("[cached.complete, cached.naturalWidth, cached.naturalHeight]")
.unwrap(),
serde_json::json!([true, 2, 3])
);
rt.execute_script(
"observe-unrelated-geometry",
r#"
globalThis.__stableGetterResizeRecords = 0;
globalThis.__stableGetterObserver = new ResizeObserver(entries => {
__stableGetterResizeRecords += entries.length;
});
__stableGetterObserver.observe(document.getElementById("probe"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate(
"[__stableGetterResizeRecords, __obscura_nextPendingTimeoutDelay()]"
)
.unwrap(),
serde_json::json!([1, -1])
);
rt.execute_script(
"read-stable-image-cache",
r#"
for (let i = 0; i < 50; i++) {
void cached.complete;
void cached.currentSrc;
void cached.naturalWidth;
void cached.naturalHeight;
}
"#,
)
.unwrap();
// Cached lifecycle reads do not change intrinsic dimensions, so they
// must not enqueue a rendering checkpoint (and its geometry walk).
assert_eq!(
rt.evaluate(
"[__stableGetterResizeRecords, __obscura_nextPendingTimeoutDelay()]"
)
.unwrap(),
serde_json::json!([1, -1])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn parser_image_source_replacement_cancels_queued_completion() {
+316 -61
View File
@@ -79,36 +79,59 @@ pub(crate) struct RelationalInvalidation {
pub unrepresentable_outer_path: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct StructuralInvalidation {
pub rule_order: usize,
pub state: String,
subject_key: Option<RelationalSelectorKey>,
pub reaches: InvalidationReaches,
pub inside_relational: bool,
}
impl StructuralInvalidation {
pub(crate) fn subject_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
relational_key_may_match(self.subject_key.as_ref(), tree, node)
}
}
fn relational_key_may_match(
key: Option<&RelationalSelectorKey>,
tree: &DomTree,
node: NodeId,
) -> bool {
let Some(dom_node) = tree.get_node(node) else {
return false;
};
if dom_node.as_element().is_none() {
return false;
}
let quirks = tree.is_quirks();
match key {
None => true,
Some(RelationalSelectorKey::Id(expected)) => dom_node
.get_attribute("id")
.is_some_and(|actual| {
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
}),
Some(RelationalSelectorKey::Class(expected)) => dom_node
.get_attribute("class")
.is_some_and(|classes| {
classes.split_whitespace().any(|actual| {
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
})
}),
Some(RelationalSelectorKey::Attribute(expected)) => {
dom_node.get_attribute(expected).is_some()
}
Some(RelationalSelectorKey::LocalName(expected)) => dom_node
.as_element()
.is_some_and(|element| element.local.as_ref().eq_ignore_ascii_case(expected)),
}
}
impl RelationalInvalidation {
pub(crate) fn anchor_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
let Some(dom_node) = tree.get_node(node) else {
return false;
};
if dom_node.as_element().is_none() {
return false;
}
let quirks = tree.is_quirks();
match self.anchor_key.as_ref() {
None => true,
Some(RelationalSelectorKey::Id(expected)) => dom_node
.get_attribute("id")
.is_some_and(|actual| {
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
}),
Some(RelationalSelectorKey::Class(expected)) => dom_node
.get_attribute("class")
.is_some_and(|classes| {
classes.split_whitespace().any(|actual| {
actual == expected || (quirks && actual.eq_ignore_ascii_case(expected))
})
}),
Some(RelationalSelectorKey::Attribute(expected)) => {
dom_node.get_attribute(expected).is_some()
}
Some(RelationalSelectorKey::LocalName(expected)) => dom_node
.as_element()
.is_some_and(|element| element.local.as_ref().eq_ignore_ascii_case(expected)),
}
relational_key_may_match(self.anchor_key.as_ref(), tree, node)
}
pub(crate) fn relative_path_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
@@ -161,6 +184,10 @@ pub struct InvalidationMap {
relational_rule_orders: Vec<usize>,
unkeyed_relational_rule_orders: Vec<usize>,
relational_invalidations: Vec<RelationalInvalidation>,
structural_invalidations: Vec<StructuralInvalidation>,
adjacent_sibling_selectors: bool,
general_sibling_selectors: bool,
unkeyed_sibling_selectors: bool,
}
impl InvalidationMap {
@@ -213,6 +240,68 @@ impl InvalidationMap {
&self.relational_invalidations
}
pub(crate) fn structural_invalidations<'a>(
&'a self,
state: &str,
) -> Vec<&'a StructuralInvalidation> {
self.structural_invalidations
.iter()
.filter(move |invalidation| invalidation.state == state)
.collect()
}
pub(crate) fn has_adjacent_sibling_selectors(&self) -> bool {
self.adjacent_sibling_selectors
}
pub(crate) fn has_general_sibling_selectors(&self) -> bool {
self.general_sibling_selectors
}
pub(crate) fn node_may_start_sibling_selector(
&self,
tree: &DomTree,
node: NodeId,
) -> bool {
if self.unkeyed_sibling_selectors
|| (tree.is_quirks()
&& (self.adjacent_sibling_selectors || self.general_sibling_selectors))
{
return true;
}
let Some(node) = tree.get_node(node) else {
return false;
};
let reaches_sibling = |dependencies: &[InvalidationDependency]| {
dependencies.iter().any(|dependency| {
dependency.reaches.contains(InvalidationReaches::SIBLINGS)
})
};
if node
.get_attribute("id")
.is_some_and(|id| reaches_sibling(self.id_dependencies(id)))
{
return true;
}
if node.get_attribute("class").is_some_and(|classes| {
classes
.split_whitespace()
.any(|class| reaches_sibling(self.class_dependencies(class)))
}) {
return true;
}
if node.attrs().is_some_and(|attributes| {
attributes.iter().any(|attribute| {
reaches_sibling(self.attribute_dependencies(attribute.name.local.as_ref()))
})
}) {
return true;
}
node.as_element().is_some_and(|element| {
reaches_sibling(self.local_name_dependencies(element.local.as_ref()))
})
}
pub fn dependency_count(&self) -> usize {
self.ids
.values()
@@ -301,6 +390,12 @@ impl InvalidationMap {
self.relational_invalidations.push(invalidation);
}
}
fn push_structural_invalidation(&mut self, invalidation: StructuralInvalidation) {
if !self.structural_invalidations.contains(&invalidation) {
self.structural_invalidations.push(invalidation);
}
}
}
fn compose_invalidation_reach(
@@ -929,11 +1024,54 @@ fn selector_contains_adjacent_combinator(selector: &str) -> bool {
false
}
/// Sibling combinators which participate in the current selector path.
///
/// Parentheses are deliberately skipped here. `:is()`/`:where()`/`:not()`
/// alternatives are fed back through dependency collection separately, while
/// `:has()` owns an upward invalidation path and must not poison ordinary
/// sibling invalidation outside its anchor.
fn selector_sibling_combinators(selector: &str) -> (bool, bool) {
let chars = selector.chars().collect::<Vec<_>>();
let mut index = 0usize;
let mut quote = None;
let mut bracket_depth = 0usize;
let mut paren_depth = 0usize;
let mut adjacent = false;
let mut general = false;
while index < chars.len() {
let ch = chars[index];
if ch == '\\' {
index = (index + 2).min(chars.len());
continue;
}
if let Some(active_quote) = quote {
if ch == active_quote {
quote = None;
}
index += 1;
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'[' => bracket_depth += 1,
']' => bracket_depth = bracket_depth.saturating_sub(1),
'(' if bracket_depth == 0 => paren_depth += 1,
')' if bracket_depth == 0 => paren_depth = paren_depth.saturating_sub(1),
'+' if bracket_depth == 0 && paren_depth == 0 => adjacent = true,
'~' if bracket_depth == 0 && paren_depth == 0 => general = true,
_ => {}
}
index += 1;
}
(adjacent, general)
}
fn note_compound_dependencies(
map: &mut InvalidationMap,
compound: &str,
reaches: InvalidationReaches,
rule_order: usize,
inside_relational: bool,
) {
if let Some(local_name) = compound_local_name(compound) {
map.push_local_name(local_name, rule_order, reaches);
@@ -1000,6 +1138,24 @@ fn note_compound_dependencies(
}
if chars.get(next) != Some(&'(') {
map.push_state(name.clone(), rule_order, reaches);
if matches!(
name.as_str(),
"empty"
| "first-child"
| "last-child"
| "only-child"
| "first-of-type"
| "last-of-type"
| "only-of-type"
) {
map.push_structural_invalidation(StructuralInvalidation {
rule_order,
state: name.clone(),
subject_key: relational_anchor_key(compound),
reaches,
inside_relational,
});
}
if matches!(
name.as_str(),
"root"
@@ -1037,6 +1193,7 @@ fn note_compound_dependencies(
alternative.trim(),
reaches,
rule_order,
!inside_relational,
);
}
}
@@ -1105,11 +1262,19 @@ fn note_compound_dependencies(
alternative.trim(),
InvalidationReaches::CONSERVATIVE,
rule_order,
false,
);
}
}
"nth-child" | "nth-last-child" | "nth-of-type" | "nth-last-of-type" => {
map.push_state(name, rule_order, reaches);
map.push_state(name.clone(), rule_order, reaches);
map.push_structural_invalidation(StructuralInvalidation {
rule_order,
state: name,
subject_key: relational_anchor_key(compound),
reaches,
inside_relational,
});
// Structural index changes and `of <complex-selector>`
// need sibling-wide bookkeeping not present in phase 1.
map.mark_conservative(rule_order);
@@ -1120,6 +1285,7 @@ fn note_compound_dependencies(
alternative.trim(),
InvalidationReaches::CONSERVATIVE,
rule_order,
!inside_relational,
);
}
}
@@ -1144,17 +1310,35 @@ fn note_selector_dependencies(
selector: &str,
outer_reaches: InvalidationReaches,
rule_order: usize,
record_tree_siblings: bool,
) {
if record_tree_siblings {
let (adjacent, general) = selector_sibling_combinators(selector);
map.adjacent_sibling_selectors |= adjacent;
map.general_sibling_selectors |= general;
}
let (compounds, malformed) = invalidation_compounds(selector);
if malformed {
map.mark_conservative(rule_order);
}
for (compound, local_reaches) in compounds {
if record_tree_siblings
&& local_reaches.contains(InvalidationReaches::SIBLINGS)
&& !relative_selector_subject_has_key(&compound)
{
map.unkeyed_sibling_selectors = true;
}
if local_reaches.contains(InvalidationReaches::CONSERVATIVE) {
map.mark_conservative(rule_order);
}
let reaches = compose_invalidation_reach(map, local_reaches, outer_reaches, rule_order);
note_compound_dependencies(map, &compound, reaches, rule_order);
note_compound_dependencies(
map,
&compound,
reaches,
rule_order,
!record_tree_siblings,
);
}
}
@@ -1163,7 +1347,13 @@ fn note_selector_for_invalidation(
selector: &str,
rule_order: usize,
) {
note_selector_dependencies(map, selector, InvalidationReaches::SELF, rule_order);
note_selector_dependencies(
map,
selector,
InvalidationReaches::SELF,
rule_order,
true,
);
}
/// Record element attributes read from declaration values.
@@ -6041,12 +6231,27 @@ fn extract_length(s: &str, prop: &str, viewport: (f32, f32), axis: LengthAxis) -
let start = s.find(prop)? + prop.len();
let rest = &s[start..];
if let Some(inner) = rest.strip_prefix("calc(") {
let end = inner.find(')')?;
let end = matching_paren_end(inner)?;
return eval_length_sum(&inner[..end], viewport, axis);
}
parse_length_prefix(rest, viewport, axis)
}
/// Find the closing parenthesis paired with an opening parenthesis immediately
/// before `input`. Nested CSS math groups must not terminate the outer calc.
fn matching_paren_end(input: &str) -> Option<usize> {
let mut depth = 0usize;
for (index, character) in input.char_indices() {
match character {
'(' => depth += 1,
')' if depth == 0 => return Some(index),
')' => depth -= 1,
_ => {}
}
}
None
}
/// Read the length immediately before a range marker (`64rem<=width`).
fn extract_length_before(
s: &str,
@@ -6056,12 +6261,23 @@ fn extract_length_before(
) -> Option<f32> {
let end = s.find(marker)?;
let prefix = &s[..end];
let start = prefix
.rfind(|c: char| matches!(c, '(' | ')' | ':' | ','))
.map_or(0, |idx| idx + 1);
let value = &prefix[start..];
let mut depth = 0usize;
let mut start = 0usize;
for (index, character) in prefix.char_indices().rev() {
match character {
')' => depth += 1,
'(' if depth > 0 => depth -= 1,
'(' | ':' | ',' if depth == 0 => {
start = index + character.len_utf8();
break;
}
_ => {}
}
}
let value = prefix[start..].trim();
if let Some(inner) = value.strip_prefix("calc(") {
return eval_length_sum(inner, viewport, axis);
let end = matching_paren_end(inner)?;
return eval_length_sum(&inner[..end], viewport, axis);
}
parse_length_prefix(value, viewport, axis)
}
@@ -6102,32 +6318,23 @@ fn parse_length_prefix(input: &str, viewport: (f32, f32), axis: LengthAxis) -> O
px.is_finite().then_some(px)
}
/// Sum the common media-query `calc()` form (`64rem - 1px`) left to right.
/// Resolve media-query `calc()` lengths with the same typed CSS math used by
/// layout. Real responsive breakpoints combine grouping and scalar arithmetic
/// (for example `calc(1rem * 2 + (15rem + 2rem) * 2 + 31rem)`), so a flat
/// plus/minus scanner silently turns those queries into unconditional rules.
fn eval_length_sum(expr: &str, viewport: (f32, f32), axis: LengthAxis) -> Option<f32> {
let mut total = 0.0;
let mut sign = 1.0;
let mut term = String::new();
let flush = |term: &mut String, sign: f32, total: &mut f32| -> Option<()> {
let value = parse_length_prefix(term, viewport, axis)?;
*total += sign * value;
term.clear();
Some(())
let percent_base = match axis {
LengthAxis::Width => viewport.0,
LengthAxis::Height => viewport.1,
};
for (idx, c) in expr.char_indices() {
match c {
'+' if idx > 0 => {
flush(&mut term, sign, &mut total)?;
sign = 1.0;
}
'-' if idx > 0 => {
flush(&mut term, sign, &mut total)?;
sign = -1.0;
}
_ => term.push(c),
}
}
flush(&mut term, sign, &mut total)?;
Some(total)
crate::style::resolve_contextual_length(
&format!("calc({expr})"),
16.0,
16.0,
viewport.0 / 100.0,
viewport.1 / 100.0,
percent_base,
)
}
fn split_media_query_list(query: &str) -> Vec<&str> {
@@ -6214,6 +6421,25 @@ mod tests {
assert!(!map.requires_conservative_invalidation());
}
#[test]
fn invalidation_map_distinguishes_tree_sibling_paths_from_has_paths() {
let map = test_invalidation_map(
r#"
.left + .right { color:red }
:is(.early ~ .late, .plain) { color:blue }
.host:has(.inside + .peer) { color:green }
[data-token="+"] { color:black }
"#,
);
assert!(map.has_adjacent_sibling_selectors());
assert!(map.has_general_sibling_selectors());
let relational_only =
test_invalidation_map(".host:has(.inside + .peer){color:red}");
assert!(!relational_only.has_adjacent_sibling_selectors());
assert!(!relational_only.has_general_sibling_selectors());
}
#[test]
fn invalidation_map_indexes_attributes_states_functions_and_escaped_keys() {
let map = test_invalidation_map(
@@ -8674,6 +8900,35 @@ mod tests {
"@media (width > calc(60em - 1px))",
(900.0, 1000.0)
));
let two_sidebar_breakpoint =
"@media (width < calc(1rem * 2 + (15rem + 2rem) * 2 + 31rem))";
assert!(media_query_applies_for_viewport(
two_sidebar_breakpoint,
(1000.0, 900.0)
));
assert!(!media_query_applies_for_viewport(
two_sidebar_breakpoint,
(1440.0, 1000.0)
));
let left_width_calc =
"@media (calc(1rem * 2 + (15rem + 2rem) * 2 + 31rem) <= width)";
assert!(!media_query_applies_for_viewport(
left_width_calc,
(1000.0, 900.0)
));
assert!(media_query_applies_for_viewport(
left_width_calc,
(1440.0, 1000.0)
));
let left_height_calc = "@media (calc(40rem + (2rem * 2)) < height)";
assert!(!media_query_applies_for_viewport(
left_height_calc,
(1280.0, 704.0)
));
assert!(media_query_applies_for_viewport(
left_height_calc,
(1280.0, 705.0)
));
}
#[test]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+56 -8
View File
@@ -8,9 +8,15 @@ use std::io::{Read, Write};
use obscura::Browser;
/// Minimal HTTP/1.1 server returning HTML that wraps document.createElement to
/// plant a non-configurable `src` on every <img>, then calls `new Image()`.
/// Minimal HTTP/1.1 server returning the test page and a valid 1x1 PNG.
fn spawn_server(html: &'static str) -> String {
const PIXEL_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78,
0xda, 0x63, 0xfc, 0xcf, 0xc0, 0x50, 0x0f, 0x00, 0x05, 0x83, 0x02, 0x7f, 0x94, 0xff,
0x2f, 0x59, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
@@ -20,13 +26,19 @@ fn spawn_server(html: &'static str) -> String {
Err(_) => continue,
};
let mut buf = [0u8; 2048];
let _ = s.read(&mut buf);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
let read = s.read(&mut buf).unwrap_or(0);
let is_pixel = buf[..read].starts_with(b"GET /pixel.png ");
let (content_type, body) = if is_pixel {
("image/png", PIXEL_PNG)
} else {
("text/html", html.as_bytes())
};
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = s.write_all(resp.as_bytes());
let _ = s.write_all(headers.as_bytes());
let _ = s.write_all(body);
let _ = s.shutdown(std::net::Shutdown::Both);
}
});
@@ -100,3 +112,39 @@ async fn new_image_still_emulates_load_when_src_is_configurable() {
"load emulation regressed for the normal (configurable src) path"
);
}
#[tokio::test]
async fn invalid_image_bytes_emit_error_like_chromium() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let base = spawn_server(
r#"<!doctype html><html><body><div id="r">waiting</div>
<script>
var img = new Image();
img.onerror = function () {
document.getElementById('r').textContent =
'error complete=' + img.complete +
' natural=' + img.naturalWidth + 'x' + img.naturalHeight;
};
img.src = '/broken.png';
</script>
</body></html>"#,
);
let browser = Browser::new().unwrap();
let mut page = browser.new_page().await.unwrap();
page.goto(&base).await.unwrap();
for _ in 0..10 {
page.settle(500).await;
let text = page.evaluate("document.getElementById('r').textContent");
if text.as_str().unwrap_or("").starts_with("error") {
break;
}
}
let text = page.evaluate("document.getElementById('r').textContent");
assert_eq!(
text.as_str().unwrap_or(""),
"error complete=true natural=0x0",
"invalid image bytes must fail instead of being treated as a decoded image"
);
}