blitz_dom/resolve.rs
1//! Resolve style and layout
2
3use blitz_traits::node_id::NodeId;
4use std::{
5 cell::RefCell,
6 collections::HashSet,
7 time::{SystemTime, UNIX_EPOCH},
8};
9
10use kurbo::{Affine, Rect};
11use parley::LayoutContext;
12use selectors::Element as _;
13use style::dom::TDocument;
14
15#[cfg(feature = "parallel-construct")]
16use rayon::prelude::*;
17
18// FIXME: static thread_local FontCtx isn't necessarily correct in multi-document context.
19// Should use thread_local crate with ThreadLocal value store in the Document.
20thread_local! {
21 pub(crate) static LAYOUT_CTX: RefCell<Option<Box<LayoutContext<TextBrush>>>> = const { RefCell::new(None) };
22}
23
24use style::properties::ComputedValues;
25use style::properties::generated::longhands::position::computed_value::T as Position;
26use style::selector_parser::RestyleDamage;
27use taffy::AvailableSpace;
28
29use crate::{
30 BaseDocument,
31 events::ScrollAnimationState,
32 layout::{
33 construct::{
34 ConstructionTask, ConstructionTaskData, ConstructionTaskResult,
35 ConstructionTaskResultData, LayoutChildren, build_inline_layout_into,
36 collect_layout_children,
37 },
38 damage::{ALL_DAMAGE, CONSTRUCT_BOX, CONSTRUCT_DESCENDENT, CONSTRUCT_FC},
39 },
40 node::TextBrush,
41};
42
43impl BaseDocument {
44 /// Pull every scroll offset back inside the content it scrolls.
45 ///
46 /// Scrolling clamps against the extent at the time of the gesture, and
47 /// nothing re-checked it afterwards. So any layout that made a scroller's
48 /// content *shorter* left the offset beyond the new end, and the view
49 /// stayed parked in space the content no longer reaches: dismiss a panel
50 /// while scrolled to the bottom and its height is simply gone from under
51 /// you, leaving a band of nothing between the last content and the edge.
52 /// Far enough past the end and there is nothing left to see at all.
53 ///
54 /// Done after layout, which is the only point at which the new extents are
55 /// known, and cheap: offsets are almost always zero.
56 fn clamp_scroll_offsets(&mut self) {
57 for (_, node) in self.nodes.iter_mut() {
58 // The accessor panics on node kinds that have no scroll offset, so
59 // ask the data first rather than every node in the tree.
60 let Some(offset) = node
61 .data
62 .downcast_element()
63 .map(|element| element.scroll_offset)
64 else {
65 continue;
66 };
67 if offset.x == 0.0 && offset.y == 0.0 {
68 continue;
69 }
70 let max_x = f64::from(node.final_layout().scroll_width()).max(0.0);
71 let max_y = f64::from(node.final_layout().scroll_height()).max(0.0);
72 let clamped = node.scroll_offset_mut();
73 clamped.x = offset.x.clamp(0.0, max_x);
74 clamped.y = offset.y.clamp(0.0, max_y);
75 }
76 }
77
78 /// Re-break any inline layout whose lines belong to a pass other than the
79 /// one that decided its box.
80 ///
81 /// Taffy performs layout under min-content and max-content constraints
82 /// while sizing a box, and every one of those passes breaks the same parley
83 /// layout the screen reads from. Whichever ran last is what gets painted.
84 /// That is usually the real layout, and when the final pass is answered
85 /// from the taffy cache it is not: `compute_inline_layout` never runs
86 /// again, and the trial break stays. Reported as "1st load is fucked" and
87 /// measured on a live transcript as paragraphs broken at 164px inside a
88 /// 1,426px box, 39 lines of one or two words each.
89 ///
90 /// Cheap by construction: it compares two floats per inline root and
91 /// re-breaks only the ones that disagree, which in a settled document is
92 /// none of them.
93 /// Returns whether any repair changed a layout's height, which means the
94 /// boxes taffy sized are now wrong and layout has to run again.
95 fn repair_inline_line_breaks(&mut self) -> bool {
96 let mut wrong = Vec::new();
97 for (node_id, node) in self.nodes.iter() {
98 let Some(inline) = node
99 .data
100 .downcast_element()
101 .and_then(|element| element.inline_layout_data.as_ref())
102 else {
103 continue;
104 };
105 // The *unrounded* layout, which is the width the layout pass
106 // broke at. `final_layout` is rounded to whole pixels, and half a
107 // pixel of rounding-down is enough to wrap a label that exactly
108 // fit: "125.1k / 200.0k ctx · 63%" came back on two lines.
109 let layout = node.unrounded_layout();
110 let content_width = (layout.size.width
111 - layout.padding.left
112 - layout.padding.right
113 - layout.border.left
114 - layout.border.right)
115 .max(0.0)
116 * inline.layout.scale();
117 // Half a device pixel: below that the break is identical and
118 // re-running it would cost a frame to change nothing.
119 if inline
120 .laid_out_at
121 .is_none_or(|broken_at| (broken_at - content_width).abs() > 0.5)
122 {
123 wrong.push((node_id, content_width));
124 }
125 }
126
127 let mut changed_height = false;
128 for (node_id, content_width) in wrong {
129 // Breaking discards the alignment the layout pass applied, so it
130 // has to go back on: without it every centred or right-aligned
131 // paragraph this touches would silently come back left-aligned.
132 let alignment = self.nodes[node_id]
133 .primary_styles()
134 .map(|style| {
135 use parley::layout::Alignment;
136 use style::values::specified::TextAlignKeyword;
137 match style.clone_text_align() {
138 TextAlignKeyword::Start => Alignment::Start,
139 TextAlignKeyword::Left | TextAlignKeyword::MozLeft => Alignment::Left,
140 TextAlignKeyword::Right | TextAlignKeyword::MozRight => Alignment::Right,
141 TextAlignKeyword::Center | TextAlignKeyword::MozCenter => Alignment::Center,
142 TextAlignKeyword::Justify => Alignment::Justify,
143 TextAlignKeyword::End => Alignment::End,
144 }
145 })
146 .unwrap_or(parley::layout::Alignment::Start);
147
148 let Some(inline) = self.nodes[node_id]
149 .data
150 .downcast_element_mut()
151 .and_then(|element| element.inline_layout_data.as_mut())
152 else {
153 continue;
154 };
155 inline.layout.break_all_lines(Some(content_width));
156 inline.layout.align(
157 alignment,
158 parley::layout::AlignmentOptions {
159 align_when_overflowing: false,
160 },
161 );
162 inline.laid_out_at = Some(content_width);
163
164 // Any repair at all invalidates the boxes around it, not just one
165 // whose parley height moved. The box was sized by a pass that broke
166 // these lines differently, and its height was accumulated into
167 // every ancestor's content size on the way up. Comparing parley
168 // heights before and after missed that: the layout being repaired
169 // is not the one the box was sized from, so it can come out the
170 // same height while the box is still wrong. Measured live as a
171 // transcript whose content ran 1,062px past the extent it reported,
172 // so it could not scroll to its own last message.
173 changed_height = true;
174 self.nodes[node_id].insert_damage(RestyleDamage::RELAYOUT);
175 }
176
177 changed_height
178 }
179
180 /// Restyle the tree and then relayout it
181 pub fn resolve(&mut self, current_time_for_animations: f64) {
182 if current_time_for_animations.is_finite() {
183 self.last_resolve_animation_time = self
184 .last_resolve_animation_time
185 .max(current_time_for_animations);
186 }
187 let current_time_for_animations = self.last_resolve_animation_time;
188
189 if TDocument::as_node(&self.root_node())
190 .first_element_child()
191 .is_none()
192 {
193 #[cfg(feature = "tracing")]
194 tracing::warn!("No DOM - not resolving");
195 return;
196 }
197
198 // Process messages that have been sent to our message channel (e.g. loaded resource)
199 self.handle_messages();
200
201 self.resolve_scroll_animation();
202
203 // Retain completed activity entries so an initially visible scrollbar
204 // stays faded after its first interaction. Only removed nodes need to
205 // shed their entry.
206 let nodes = &self.nodes;
207 self.scrollbar_activity
208 .retain(|node_id, _| nodes.contains_key(*node_id));
209
210 let root_node_id = self.root_element().id;
211 #[cfg(feature = "log-phase-times")]
212 let mut timer =
213 debug_timer::RealDebugTimer::init_if(blitz_traits::profiling::deep_profiling_enabled());
214 #[cfg(not(feature = "log-phase-times"))]
215 let mut timer = debug_timer::DummyDebugTimer::init();
216 #[cfg(feature = "log-phase-times")]
217 crate::layout::layout_counters::begin(blitz_traits::profiling::deep_profiling_enabled());
218
219 // Compute the shadow DOM flattened tree (shadow-root composition and
220 // <slot> distribution). This must happen *before* style resolution so
221 // that Stylo traverses the composed (flattened) tree and styles shadow
222 // content, and before box construction consumes it.
223 #[cfg(feature = "shadow-dom")]
224 {
225 self.compute_flattened_trees();
226 timer.record_time("shadow");
227 }
228
229 // we need to resolve stylist first since it will need to drive our layout bits
230 self.resolve_stylist(current_time_for_animations);
231 timer.record_time("style");
232
233 self.paint_damage.begin_resolve();
234
235 // Propagate damage flags (from mutation and restyles) up and down the tree
236 if self.incremental_layout {
237 self.propagate_damage_flags(root_node_id, RestyleDamage::empty());
238 timer.record_time("damage");
239 }
240 // Anything after this point sees propagated damage, in which every
241 // ancestor up to the root is marked. Recording repaints from there
242 // would describe every frame as a full-frame repaint.
243 self.paint_damage.end_propagation();
244
245 // Fix up tree for layout (insert anonymous blocks as necessary, etc)
246 self.resolve_layout_children();
247 timer.record_time("construct");
248
249 self.resolve_deferred_tasks();
250 timer.record_time("pconstruct");
251
252 self.hoist_fixed_position_nodes();
253 timer.record_time("hoist");
254
255 // Merge stylo into taffy
256 self.flush_styles_to_layout(root_node_id);
257 timer.record_time("flush");
258
259 // Next we resolve layout with the data resolved by stlist
260 //
261 // Caught, under `BLITZ_TRACE_LAYOUT_PANIC=1` only, so the markup that
262 // killed layout can be printed before the process goes. The panic hook
263 // that names the element runs without the document and can only give an
264 // id and a class list; the id is worthless once the process is gone,
265 // and a class list is not markup you can put in a test. This is the one
266 // place that still holds `&mut self` when layout fails, so it is the
267 // only place the subtree can be serialized. The panic is resumed
268 // immediately: nothing here makes a failed layout survivable.
269 #[cfg(not(target_arch = "wasm32"))]
270 if crate::layout::layout_panic_probe::enabled() {
271 let attempt =
272 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.resolve_layout()));
273 if let Err(payload) = attempt {
274 if let Some(node_id) = crate::layout::layout_panic_probe::innermost_node() {
275 if let Some(node) = self.nodes.get(node_id) {
276 eprintln!(
277 "[blitz-layout-panic] markup of node {node_id}:\n{}",
278 node.outer_html_pretty()
279 );
280 }
281 }
282 std::panic::resume_unwind(payload);
283 }
284 } else {
285 self.resolve_layout();
286 }
287 #[cfg(target_arch = "wasm32")]
288 self.resolve_layout();
289 self.resolve_sticky_positions();
290 self.resolve_fixed_positions();
291 self.resolve_hoisted_positions();
292 self.correct_hoisted_fixed_positions();
293 self.resolve_hoisted_clips();
294 timer.record_time("layout");
295
296 // One extra pass, only when a repair moved a box. Bounded deliberately:
297 // the second layout runs against lines that already agree with their
298 // widths, so a third could not find anything new, and an unbounded loop
299 // here would be a hang rather than a slow frame.
300 if self.repair_inline_line_breaks() {
301 // Damage first. The repair marks the nodes it touched, but a box's
302 // height is accumulated into every ancestor's content size on the
303 // way up, and those ancestors answer from the taffy cache until
304 // damage propagation clears it. Without this the second pass runs
305 // and changes nothing: measured live as a scroller still reporting
306 // an extent 1,062px short of its own content.
307 if self.incremental_layout {
308 self.propagate_damage_flags(root_node_id, RestyleDamage::empty());
309 }
310 self.flush_styles_to_layout(root_node_id);
311 self.resolve_layout();
312 self.resolve_sticky_positions();
313 self.resolve_fixed_positions();
314 self.resolve_hoisted_positions();
315 self.correct_hoisted_fixed_positions();
316 self.resolve_hoisted_clips();
317 self.repair_inline_line_breaks();
318 self.resolve_transforms(root_node_id);
319 }
320
321 self.clamp_scroll_offsets();
322 self.resolve_hoisted_positions();
323 self.correct_hoisted_fixed_positions();
324 self.resolve_hoisted_clips();
325 self.trace_escaped_inline_fragments();
326
327 // Resolve transforms
328 self.resolve_transforms(root_node_id);
329 timer.record_time("transform");
330
331 // Boxes are final here, which is what the geometry half compares. It
332 // runs before the clearing loop below only because both walk the node
333 // list and doing them together saves nothing: this one needs `&nodes`
334 // while that one needs `&mut`.
335 if self.paint_damage.is_enabled() {
336 let mut tracker = std::mem::take(&mut self.paint_damage);
337 tracker.capture(&self.nodes);
338 self.paint_damage = tracker;
339 timer.record_time("paint_damage");
340 }
341
342 // Clear all damage and dirty flags
343 if self.incremental_layout {
344 for (_, node) in self.nodes.iter_mut() {
345 node.clear_damage_mut();
346 node.unset_dirty_descendants();
347 }
348 timer.record_time("c_damage");
349 }
350
351 // Re-resolve the hover node from the pointer position against the fresh
352 // layout. This must run *after* the damage/dirty flags are cleared
353 // above, so that the restyle hint and ancestor `dirty_descendants`
354 // flags set by any resulting hover change survive into the next resolve
355 // pass (the clearing loop would otherwise wipe them). Any resulting
356 // restyle is picked up on the next resolve pass; a redraw is requested
357 // if the hovered node actually changes.
358 self.refresh_hover();
359
360 let mut subdoc_animation_pacing = crate::document::AnimationPacing::Idle;
361 for &node_id in &self.sub_document_nodes {
362 let node = &mut self.nodes[node_id];
363 let size = node.final_layout().size;
364 if let Some(mut sub_doc) = node.subdoc_mut().map(|doc| doc.inner_mut()) {
365 // Set viewport
366 // viewport_mut handles change detection. So we just unconditionally set the values;
367 let mut sub_viewport = sub_doc.viewport_mut();
368 sub_viewport.hidpi_scale = self.viewport.hidpi_scale;
369 sub_viewport.zoom = self.viewport.zoom;
370 sub_viewport.color_scheme = self.viewport.color_scheme;
371
372 let viewport_scale = self.viewport.scale();
373 sub_viewport.window_size = (
374 (size.width * viewport_scale) as u32,
375 (size.height * viewport_scale) as u32,
376 );
377 drop(sub_viewport);
378
379 sub_doc.resolve(current_time_for_animations);
380
381 subdoc_animation_pacing = subdoc_animation_pacing.max(sub_doc.animation_pacing());
382 }
383 }
384 self.subdoc_animation_pacing = subdoc_animation_pacing;
385 timer.record_time("subdocs");
386
387 // Printed with the phases so a single line says both how long layout
388 // took and how much of the tree it touched. Without the counts the
389 // timings cannot distinguish a few slow nodes from a cache miss across
390 // the document, and those need opposite fixes.
391 #[cfg(feature = "log-phase-times")]
392 {
393 // The offenders are read, and the message built, only when a sink
394 // is configured: the counters are cheap to keep and expensive to
395 // describe, and this feature now travels with a shipped binary.
396 // Draining, though, is unconditional — `layout_counters::last()` is
397 // what the benchmarks read, and counts that are never taken keep
398 // accumulating across resolves.
399 let describe = timer.is_logging();
400 if describe {
401 // Named before the counters are drained, and only when the pass
402 // was expensive enough to be worth looking at.
403 let offenders = crate::layout::layout_counters::worst_offenders(6);
404 if offenders.first().is_some_and(|(_, count)| *count > 8) {
405 let described: Vec<String> = offenders
406 .iter()
407 .map(|(id, count)| {
408 let tag = self
409 .nodes
410 .get(*id)
411 .and_then(|node| node.element_data())
412 .map(|element| element.name.local.to_string())
413 .unwrap_or_else(|| "?".to_string());
414 let display = self
415 .nodes
416 .get(*id)
417 .map(|node| format!("{:?}", node.style().display))
418 .unwrap_or_default();
419 format!("{id:?}:{tag}({display})x{count}")
420 })
421 .collect();
422 debug_timer::log_line(&format!(" layout hotspots: {}\n", described.join(" ")));
423 }
424 }
425 let counts = crate::layout::layout_counters::take();
426 if describe {
427 let total_nodes = self.nodes.len();
428 let hit_rate = if counts.lookups > 0 {
429 (counts.hits as f64 / counts.lookups as f64) * 100.0
430 } else {
431 0.0
432 };
433 timer.print_times(&format!(
434 "Resolve({}) [computed {} over {} distinct of {total_nodes} nodes, \
435 cache {}/{} hits {hit_rate:.0}%, {} cleared]: ",
436 self.id(),
437 counts.computed,
438 counts.distinct,
439 counts.hits,
440 counts.lookups,
441 counts.caches_cleared,
442 ));
443 }
444 }
445 #[cfg(not(feature = "log-phase-times"))]
446 timer.print_times(&format!("Resolve({}): ", self.id()));
447 }
448
449 fn resolve_transforms(&mut self, node_id: NodeId) -> Rect {
450 if !self.nodes.contains_key(node_id) {
451 return Rect::ZERO;
452 }
453
454 if !self.nodes[node_id]
455 .damage()
456 .map(|d| d.contains(style::selector_parser::RestyleDamage::RECALCULATE_OVERFLOW))
457 .unwrap_or(false)
458 {
459 return *self.nodes[node_id].scrollable_overflow();
460 }
461
462 let scale = self.viewport.scale_f64();
463
464 let transform = self.nodes[node_id].set_transform(scale as f32);
465
466 let w = self.nodes[node_id].final_layout().size.width as f64 * scale;
467 let h = self.nodes[node_id].final_layout().size.height as f64 * scale;
468 let mut overflow = Rect::new(0.0, 0.0, w, h);
469
470 let layout_children = std::mem::take(self.nodes[node_id].layout_children.get_mut());
471
472 if let Some(ref children) = layout_children {
473 for &child_id in children {
474 let child_rect_in_self = self.resolve_transforms(child_id);
475 overflow = overflow.union(child_rect_in_self);
476 }
477 }
478 if let Some(before) = self.nodes[node_id].before() {
479 let child_rect_in_self = self.resolve_transforms(before);
480 overflow = overflow.union(child_rect_in_self);
481 }
482 if let Some(after) = self.nodes[node_id].after() {
483 let child_rect_in_self = self.resolve_transforms(after);
484 overflow = overflow.union(child_rect_in_self);
485 }
486
487 // Text overflows too, and only layout *children* were counted above.
488 //
489 // Glyph runs are not nodes, so a `white-space: nowrap` line wider than
490 // its box left `scrollable_overflow` exactly equal to that box. Paint
491 // skips its clip layer when the overflow rect fits the border box —
492 // most `overflow-hidden` wrappers really do clip nothing, and a layer
493 // is the most expensive thing in a frame — so the one case that needed
494 // the clip was the one case that reported it was unnecessary. A
495 // truncated tab title painted straight through the close button beside
496 // it, a branch name through the chip after it, and a transcript line
497 // under the cost readout: measured here as a 150px box painting its
498 // text out to x=354.
499 if let Some(inline_layout) = self.nodes[node_id]
500 .data
501 .downcast_element()
502 .and_then(|element| element.inline_layout_data.as_ref())
503 {
504 // Convert Parley's stored scale into this viewport's device
505 // pixels, matching the conversion used to paint its glyphs.
506 let text_scale = scale / inline_layout.layout.scale() as f64;
507 let text_width = inline_layout.layout.width() as f64 * text_scale;
508 let text_height = inline_layout.layout.height() as f64 * text_scale;
509 overflow = overflow.union(Rect::new(0.0, 0.0, text_width, text_height));
510 }
511
512 *self.nodes[node_id].scrollable_overflow_mut() = overflow;
513 *self.nodes[node_id].layout_children.get_mut() = layout_children;
514
515 let scaled_x = self.nodes[node_id].final_layout().location.x as f64 * scale;
516 let scaled_y = self.nodes[node_id].final_layout().location.y as f64 * scale;
517
518 let full = if let Some(t) = transform {
519 Affine::translate((scaled_x, scaled_y)) * t
520 } else {
521 Affine::translate((scaled_x, scaled_y))
522 };
523
524 full.transform_rect_bbox(overflow)
525 }
526
527 pub fn resolve_scroll_animation(&mut self) {
528 match &mut self.scroll_animation {
529 ScrollAnimationState::Fling(fling_state) => {
530 let time_ms = SystemTime::now()
531 .duration_since(UNIX_EPOCH)
532 .unwrap()
533 .as_millis() as u64 as f64;
534
535 let time_diff_ms = time_ms - fling_state.last_seen_time;
536
537 // 0.95 @ 60fps normalized to actual frame times
538 let deceleration = 1.0 - ((0.05 / 16.66666) * time_diff_ms);
539
540 fling_state.x_velocity *= deceleration;
541 fling_state.y_velocity *= deceleration;
542 fling_state.last_seen_time = time_ms;
543 let fling_state = fling_state.clone();
544
545 let dx = fling_state.x_velocity * time_diff_ms;
546 let dy = fling_state.y_velocity * time_diff_ms;
547
548 self.scroll_by(Some(fling_state.target), dx, dy, &mut |_| {});
549 if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
550 self.scroll_animation = ScrollAnimationState::None;
551 }
552 }
553 ScrollAnimationState::None => {
554 // Do nothing
555 }
556 }
557 }
558
559 /// Restore authored fixed-node slots before reconstructing the box tree.
560 ///
561 /// Hoisting removes these children from their original parents and appends
562 /// them to the root. Rebuilding the root while retaining an original
563 /// parent's cache would otherwise discard the only remaining tree edge.
564 /// The next hoisting walk could not find the node, even though its DOM,
565 /// styles and previously computed geometry were still present.
566 fn restore_fixed_children_for_construction(&mut self) {
567 if self.hoisted_fixed_parents.is_empty() {
568 return;
569 }
570
571 let root_id = self.root_element().id;
572 let mut restored: Vec<_> = self
573 .hoisted_fixed_parents
574 .iter()
575 .filter_map(|(&node_id, &parent_id)| {
576 let node = self.nodes.get(node_id)?;
577 if !node.flags.is_in_document()
578 || node.layout_parent.get() != Some(root_id)
579 || !self.nodes.contains_key(parent_id)
580 {
581 return None;
582 }
583 let index = self.hoisted_fixed_indices.get(&node_id).copied()?;
584 Some((node_id, parent_id, index))
585 })
586 .collect();
587 restored.sort_by_key(|entry| entry.2);
588
589 // Remove obsolete root edges as well as the live edges restored below.
590 if let Some(children) = self.nodes[root_id].layout_children.get_mut().as_mut() {
591 children.retain(|id| !self.hoisted_fixed_parents.contains_key(id));
592 }
593 self.hoisted_fixed_parents.clear();
594 self.hoisted_fixed_indices.clear();
595
596 for (node_id, parent_id, index) in restored {
597 if let Some(children) = self.nodes[parent_id].layout_children.get_mut().as_mut() {
598 children.retain(|id| *id != node_id);
599 children.insert(index.min(children.len()), node_id);
600 }
601 // A missing parent cache must be reconstructed from current DOM,
602 // rather than replaced with a partial list of old fixed children.
603 self.nodes[node_id].layout_parent.set(Some(parent_id));
604 }
605
606 // This changes only the representation used during construction.
607 // Hoisting runs again before style flushing and layout, so an unchanged
608 // tree retains its layout caches. The hypothetical-position pass has a
609 // separate restore/finish pair that invalidates its layout inputs.
610 }
611
612 /// Ensure that the layout_children field is populated for all nodes
613 pub fn resolve_layout_children(&mut self) {
614 self.restore_fixed_children_for_construction();
615 resolve_layout_children_recursive(self, self.root_node().id);
616
617 fn resolve_layout_children_recursive(doc: &mut BaseDocument, node_id: NodeId) {
618 // Anonymous blocks and pseudo-elements can be removed from the slab
619 // between render passes. Bail out rather than panicking on a stale key.
620 if doc.nodes.get(node_id).is_none() {
621 return;
622 }
623
624 let mut damage = doc.nodes[node_id].damage().unwrap_or(ALL_DAMAGE);
625 let _flags = doc.nodes[node_id].flags;
626
627 // A hidden subtree keeps the boxes it already has.
628 //
629 // `display: none` means "do not lay this out", not "forget what you
630 // know about it". Collecting layout children for a hidden container
631 // yields an empty list, so hiding a pane used to discard every box
632 // and every shaped inline layout beneath it, and revealing it built
633 // all of them again from the DOM. In an application that retains
634 // its tabs and toggles them by class, that is the entire cost of a
635 // tab switch, paid again on every switch: measured on six retained
636 // panes of a real project tab, a *re-reveal* cost exactly what the
637 // first reveal cost, 46,526 layout computations either way.
638 //
639 // Damage is deliberately left in place rather than cleared. Content
640 // that changes while hidden still carries its damage to the reveal,
641 // where the normal path reconstructs precisely what changed.
642 if doc.incremental_layout
643 && doc.nodes[node_id].is_display_none()
644 && doc.nodes[node_id].layout_children.borrow().is_some()
645 {
646 return;
647 }
648
649 // A node that has never been constructed has no boxes to keep, and
650 // no damage either once its styles survive being hidden: a pane
651 // that was hidden before it was ever shown reaches its reveal with
652 // valid styles, nothing marked dirty, and nothing to lay out. It
653 // used to be rescued by stylo discarding those styles. Ask the
654 // boxes instead of the damage.
655 let never_constructed = doc.nodes[node_id].layout_children.borrow().is_none();
656
657 if !doc.incremental_layout
658 || never_constructed
659 || damage.intersects(CONSTRUCT_FC | CONSTRUCT_BOX)
660 {
661 //} || flags.contains(NodeFlags::IS_INLINE_ROOT) {
662
663 // Deallocate the anonymous blocks created for this node in the
664 // previous construction round. They live only in the slab, so
665 // reconstructing without freeing them would leak a slab entry per
666 // anonymous block per reconstruction.
667 let old_anonymous_blocks = std::mem::take(&mut doc.nodes[node_id].anonymous_blocks);
668 for anon_id in old_anonymous_blocks {
669 doc.deallocate_anonymous_block(anon_id);
670 }
671
672 let mut collected = LayoutChildren::default();
673 collect_layout_children(doc, node_id, &mut collected);
674 let layout_children = collected.children;
675 doc.nodes[node_id].anonymous_blocks = collected.anonymous_blocks;
676
677 // Recurse into newly collected layout children
678 for child_id in layout_children.iter().copied() {
679 resolve_layout_children_recursive(doc, child_id);
680 doc.nodes[child_id].layout_parent.set(Some(node_id));
681 if let Some(mut data) = doc.nodes[child_id]
682 .stylo_element_data_opt_mut()
683 .and_then(|s| s.get_mut())
684 {
685 data.damage
686 .remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX);
687 }
688 }
689
690 *doc.nodes[node_id].layout_children.borrow_mut() = Some(layout_children.clone());
691 // *doc.nodes[node_id].paint_children.borrow_mut() = Some(layout_children);
692
693 damage.remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX);
694 // damage.insert(RestyleDamage::RELAYOUT | RestyleDamage::REPAINT);
695 } else {
696 //if damage.contains(CONSTRUCT_DESCENDENT) {
697 let layout_children = doc.nodes[node_id].layout_children.borrow_mut().take();
698 if let Some(layout_children) = layout_children {
699 for child_id in layout_children.iter().copied() {
700 // Anonymous blocks and pseudo-elements can be removed from the
701 // slab between render passes; skip stale IDs.
702 if !doc.nodes.contains_key(child_id) {
703 continue;
704 }
705 resolve_layout_children_recursive(doc, child_id);
706 doc.nodes[child_id].layout_parent.set(Some(node_id));
707 }
708
709 *doc.nodes[node_id].layout_children.borrow_mut() = Some(layout_children);
710 }
711
712 // damage.remove(CONSTRUCT_DESCENDENT);
713 // damage.insert(RestyleDamage::RELAYOUT | RestyleDamage::REPAINT);
714 }
715
716 doc.nodes[node_id].set_damage(damage);
717 }
718 }
719
720 /// Reparent `position: fixed` nodes onto the root element for layout.
721 ///
722 /// Taffy has no `Fixed` position, so `stylo_taffy` maps it to `Absolute`. An
723 /// absolutely positioned node resolves its insets against its containing
724 /// block, which for a fixed node must be the viewport. Laid out in place it
725 /// would instead resolve against the nearest positioned ancestor, so both its
726 /// offset and — when opposite insets are set — its size come out wrong.
727 ///
728 /// Reparenting them onto the root element takes the positioned ancestor out
729 /// of the picture. This runs after `resolve_layout_children` and before
730 /// `flush_styles_to_layout`, which derives `paint_children` from
731 /// `layout_children`, so painting and hit testing follow the hoist without
732 /// further work.
733 ///
734 /// The containing-block pass uses viewport dimensions independently of
735 /// the root element's content height. It temporarily restores authored
736 /// child slots to obtain static positions before returning these boxes
737 /// to the root for painting and viewport scrolling.
738 ///
739 /// A transformed ancestor becomes the containing block for its fixed
740 /// descendants, so those are left where they are.
741 ///
742 /// <https://drafts.csswg.org/css-position/#fixed-pos>
743 /// <https://drafts.csswg.org/css-transforms-1/#propdef-transform>
744 ///
745 /// The same walk collects `position: sticky` nodes into
746 /// [`Self::sticky_nodes`]. Nothing about the two is related, but both need
747 /// one pre-order pass over the box tree, and a second walk would be paid on
748 /// every frame of every document whether or not it has a sticky box in it.
749 pub fn hoist_fixed_position_nodes(&mut self) {
750 let root_id = self.root_element().id;
751
752 let mut hoisted: Vec<NodeId> = Vec::new();
753 let mut sticky: Vec<NodeId> = Vec::new();
754 let root_transformed = self.nodes[root_id]
755 .primary_styles()
756 .is_some_and(|styles| establishes_containing_block(&styles));
757 let mut escaped = if self.abspos_candidates_dirty || !self.incremental_layout {
758 Some(Vec::new())
759 } else {
760 None
761 };
762 collect_fixed(
763 self,
764 root_id,
765 root_transformed,
766 &mut hoisted,
767 &mut sticky,
768 &mut escaped,
769 );
770 if let Some(candidates) = escaped {
771 self.abspos_candidate_ids = candidates
772 .iter()
773 .map(|candidate| candidate.node_id)
774 .collect();
775 let ids = &self.abspos_candidate_ids;
776 self.abspos_inputs.retain(|id, _| ids.contains(id));
777 self.abspos_written.retain(|id| ids.contains(id));
778 self.abspos_candidates = candidates;
779 self.abspos_candidates_dirty = false;
780 self.abspos_layout_dirty = true;
781 }
782 self.sticky_nodes = sticky;
783 self.fixed_nodes = hoisted.clone();
784
785 // Drop nodes that are no longer fixed, and keep the rest.
786 //
787 // This used to `clear()` and rebuild, which worked exactly once. The
788 // loop below reads `layout_parent` to learn where a node came from, but
789 // the hoist itself sets that to the root, so on the second pass every
790 // already-hoisted node takes the `parent_id == root_id` branch and is
791 // never re-recorded. Combined with the clear, the map came back empty
792 // and `flush_styles_to_layout` put the layer in the root's stacking
793 // context instead of the one its box tree gives it.
794 //
795 // The symptom was a full-bleed background that painted correctly on the
796 // first frame and disappeared on the next relayout, which on a real
797 // page means as soon as an image finishes loading.
798 let still_fixed: HashSet<NodeId> = hoisted.iter().copied().collect();
799 self.hoisted_fixed_parents
800 .retain(|node_id, _| still_fixed.contains(node_id));
801 self.hoisted_fixed_indices
802 .retain(|node_id, _| still_fixed.contains(node_id));
803
804 for node_id in hoisted {
805 let Some(parent_id) = self.nodes[node_id].layout_parent.get() else {
806 continue;
807 };
808 if parent_id == root_id {
809 continue;
810 }
811
812 // Remember where it came from. The hoist decides the containing
813 // block; the box tree still decides the stacking context, and
814 // `flush_styles_to_layout` reads this to keep them apart.
815 self.hoisted_fixed_parents.insert(node_id, parent_id);
816
817 if let Some(children) = self.nodes[parent_id].layout_children.borrow_mut().as_mut() {
818 if let Some(index) = children.iter().position(|id| *id == node_id) {
819 self.hoisted_fixed_indices.insert(node_id, index);
820 }
821 children.retain(|id| *id != node_id);
822 }
823 if let Some(children) = self.nodes[root_id].layout_children.borrow_mut().as_mut() {
824 children.push(node_id);
825 }
826 self.nodes[node_id].layout_parent.set(Some(root_id));
827 }
828
829 fn collect_fixed(
830 doc: &BaseDocument,
831 node_id: NodeId,
832 under_transform: bool,
833 out: &mut Vec<NodeId>,
834 sticky: &mut Vec<NodeId>,
835 escaped: &mut Option<Vec<crate::layout::abspos::AbsposCandidate>>,
836 ) {
837 let children = doc.nodes[node_id].layout_children.borrow().clone();
838 let Some(children) = children else {
839 return;
840 };
841
842 for child_id in children {
843 let Some(child) = doc.nodes.get(child_id) else {
844 continue;
845 };
846 let Some(styles) = child.primary_styles() else {
847 continue;
848 };
849
850 // A hidden subtree generates no boxes, so nothing in it may be
851 // hoisted. This walk had no display check because it could not
852 // reach a hidden subtree: hiding a pane emptied its layout
853 // children and stylo discarded its styles, so the recursion
854 // stopped and `primary_styles` returned None. Now that a hidden
855 // pane keeps both, every `position: fixed` element in every
856 // background tab was hoisted onto the root and painted over the
857 // tab in front, one ghost per retained tab.
858 if styles.clone_display().is_none() {
859 continue;
860 }
861
862 let position = styles.clone_position();
863 match position {
864 Position::Fixed if !under_transform => out.push(child_id),
865 Position::Sticky => sticky.push(child_id),
866 _ => {}
867 }
868
869 if let Some(escaped) = escaped.as_mut() {
870 if position.is_absolutely_positioned() {
871 let cb = doc.abspos_containing_block(child_id, position == Position::Fixed);
872 if cb != child.layout_parent.get() {
873 escaped.push(crate::layout::abspos::AbsposCandidate {
874 node_id: child_id,
875 containing_block: cb,
876 });
877 }
878 }
879 }
880
881 collect_fixed(
882 doc,
883 child_id,
884 under_transform || establishes_containing_block(&styles),
885 out,
886 sticky,
887 escaped,
888 );
889 }
890 }
891
892 /// Whether a node becomes the containing block for fixed descendants.
893 ///
894 /// TODO: `filter`, `backdrop-filter`, `will-change`, `contain` and
895 /// `perspective` also do this.
896 fn establishes_containing_block(styles: &ComputedValues) -> bool {
897 crate::layout::abspos::establishes_transform_containing_block(styles)
898 }
899 }
900
901 /// Hold every viewport-anchored `position: fixed` box still while the page
902 /// scrolls under it.
903 ///
904 /// Paint translates the whole tree by the negated viewport scroll, and a
905 /// hoisted fixed layer is in that tree like everything else, so a fixed box
906 /// held its document position and left the screen exactly like flow
907 /// content: an overlay authored `top: 0` painted at screen y=-800 on a page
908 /// scrolled 800px down. Its document position has to track the scroll for
909 /// its screen position to hold still, which is the whole of the correction.
910 ///
911 /// Boxes under a transformed ancestor are left alone: that ancestor is
912 /// their containing block, so they are not viewport-anchored at all and
913 /// `hoist_fixed_position_nodes` does not collect them.
914 ///
915 /// Sizing and insets are resolved against the viewport by the
916 /// containing-block layout pass. This method applies scrolling only.
917 pub(crate) fn resolve_fixed_positions(&mut self) {
918 let scroll = self.viewport_scroll;
919 if scroll == self.fixed_scroll_offset {
920 return;
921 }
922 let delta_x = (scroll.x - self.fixed_scroll_offset.x) as f32;
923 let delta_y = (scroll.y - self.fixed_scroll_offset.y) as f32;
924
925 for &node_id in self.fixed_nodes.iter() {
926 let Some(node) = self.nodes.get_mut(node_id) else {
927 continue;
928 };
929 let location = &mut node.final_layout_mut().location;
930 location.x += delta_x;
931 location.y += delta_y;
932 }
933 self.fixed_scroll_offset = scroll;
934 }
935
936 /// Hold every `position: sticky` box against the edge of its scrollport.
937 ///
938 /// A sticky box lays out in flow, reserving its space there, and is then
939 /// displaced so that it stays between its own flow position and the far
940 /// edge of its containing block. `stylo_taffy` maps `Position::Sticky` onto
941 /// `taffy::Position::Relative`, which gets the first half right and does
942 /// nothing at all about the second, so every sticky navbar and document
943 /// sidebar on the fleet simply scrolled away, on documents 5,000 to
944 /// 16,700px tall.
945 ///
946 /// The displacement is written straight into the box's
947 /// `final_layout().location` rather than kept beside it. Paint, hit testing
948 /// and `absolute_position` all read that one field, so writing it is what
949 /// makes them agree; a parallel offset would have to be threaded through
950 /// each of them and would disagree the moment one was missed. The cost is
951 /// that this pass has to be able to recover the flow position it started
952 /// from, which is what [`Self::sticky_offsets`] records.
953 ///
954 /// Runs after layout, which is the only point at which the boxes it reads
955 /// exist, and again after a scroll, because a wheel event does not
956 /// necessarily produce a style and layout pass.
957 ///
958 /// <https://drafts.csswg.org/css-position/#stickypos-insets>
959 pub(crate) fn resolve_sticky_positions(&mut self) {
960 if self.sticky_nodes.is_empty() && self.sticky_offsets.is_empty() {
961 return;
962 }
963
964 // Put every box back at its flow position first. Recomputing from an
965 // already-displaced box would compound the offset, walking a header
966 // down the page one scroll at a time, and a node that stopped being
967 // sticky since the last pass is not in `sticky_nodes` to be corrected
968 // any other way.
969 for (&node_id, offset) in self.sticky_offsets.iter() {
970 let Some(node) = self.nodes.get_mut(node_id) else {
971 continue;
972 };
973 let location = &mut node.final_layout_mut().location;
974 location.x -= offset.x;
975 location.y -= offset.y;
976 }
977 self.sticky_offsets.clear();
978
979 let nodes = std::mem::take(&mut self.sticky_nodes);
980 for &node_id in nodes.iter() {
981 let Some(offset) = self.sticky_offset_of(node_id) else {
982 continue;
983 };
984 if offset.x == 0.0 && offset.y == 0.0 {
985 continue;
986 }
987 let location = &mut self.nodes[node_id].final_layout_mut().location;
988 location.x += offset.x;
989 location.y += offset.y;
990 self.sticky_offsets.insert(node_id, offset);
991 }
992 self.sticky_nodes = nodes;
993 }
994
995 /// The displacement `node_id` needs to stay inside its sticky view
996 /// rectangle, given the boxes and scroll offsets as they stand.
997 ///
998 /// Every coordinate here is a painted document coordinate:
999 /// `absolute_position` has already applied each ancestor scroll offset, so
1000 /// a box and the scrollport it is measured against are directly comparable.
1001 /// The one scroll offset it does not apply is the viewport's, which paint
1002 /// subtracts from the whole document, so a box that holds still on screen
1003 /// is one whose document coordinate tracks the viewport scroll.
1004 fn sticky_offset_of(&self, node_id: NodeId) -> Option<taffy::Point<f32>> {
1005 let node = self.nodes.get(node_id)?;
1006 let styles = node.primary_styles()?;
1007 if styles.clone_position() != Position::Sticky {
1008 return None;
1009 }
1010
1011 let size = node.final_layout().size;
1012 let base = node.absolute_position(0.0, 0.0);
1013
1014 // The sticky view rectangle: the padding box of the nearest scroll
1015 // container, or the viewport when there is none above it.
1016 let (port_x, port_y, port_width, port_height) = match self.nearest_scrollport(node_id) {
1017 Some(scroller_id) => {
1018 let scroller = &self.nodes[scroller_id];
1019 let layout = *scroller.final_layout();
1020 let origin = scroller.absolute_position(0.0, 0.0);
1021 (
1022 origin.x + layout.border.left,
1023 origin.y + layout.border.top,
1024 layout.size.width - layout.border.left - layout.border.right,
1025 layout.size.height - layout.border.top - layout.border.bottom,
1026 )
1027 }
1028 None => {
1029 let scale = self.viewport.scale();
1030 (
1031 self.viewport_scroll.x as f32,
1032 self.viewport_scroll.y as f32,
1033 self.viewport.window_size.0 as f32 / scale,
1034 self.viewport.window_size.1 as f32 / scale,
1035 )
1036 }
1037 };
1038
1039 let insets = styles.get_position();
1040 // Percentages resolve against the sticky view rectangle, which is the
1041 // rectangle the inset is measured inside.
1042 let inset = |value: &style::values::computed::Inset, basis: f32| {
1043 use style::values::generics::position::GenericInset as Inset;
1044 match value {
1045 Inset::LengthPercentage(value) => Some(
1046 value
1047 .resolve(style::values::computed::Length::new(basis))
1048 .px(),
1049 ),
1050 _ => None,
1051 }
1052 };
1053
1054 // Where the box may travel: its containing block, which is its layout
1055 // parent's padding box, taken in the same painted coordinates. The
1056 // parent's own scroll offset is subtracted because the region scrolls
1057 // with the content, and the extent is widened to the scrollable content
1058 // so that a sticky box whose parent *is* the scroller stays held for
1059 // the whole scroll rather than only across one scrollport.
1060 let parent = node
1061 .layout_parent
1062 .get()
1063 .and_then(|parent_id| self.nodes.get(parent_id))?;
1064 let (block_x, block_y, block_width, block_height) = {
1065 let layout = *parent.final_layout();
1066 let origin = parent.absolute_position(0.0, 0.0);
1067 let scroll = *parent.scroll_offset();
1068 (
1069 origin.x + layout.border.left - scroll.x as f32,
1070 origin.y + layout.border.top - scroll.y as f32,
1071 (layout.size.width - layout.border.left - layout.border.right)
1072 .max(layout.content_size.width),
1073 (layout.size.height - layout.border.top - layout.border.bottom)
1074 .max(layout.content_size.height),
1075 )
1076 };
1077
1078 let axis = |start: Option<f32>,
1079 end: Option<f32>,
1080 base: f32,
1081 extent: f32,
1082 port_start: f32,
1083 port_extent: f32,
1084 block_start: f32,
1085 block_extent: f32| {
1086 // `start` (top/left) pushes the box away from the near edge of the
1087 // scrollport; `end` (bottom/right) pulls it back from the far one.
1088 // A box can satisfy both at once when it is smaller than the
1089 // rectangle between them; when it cannot, the near edge wins, so
1090 // that constraint is applied second.
1091 let mut offset = 0.0f32;
1092 if let Some(end) = end {
1093 offset = (port_start + port_extent - end - extent - base).min(0.0);
1094 }
1095 if let Some(start) = start {
1096 let shift = (port_start + start - base).max(0.0);
1097 if shift > 0.0 {
1098 offset = shift;
1099 }
1100 }
1101
1102 // Stickiness ends where the containing block does: a box pinned for
1103 // ever would escape its own section and float over the next one.
1104 let furthest = (block_start + block_extent - extent - base).max(0.0);
1105 let nearest = (block_start - base).min(0.0);
1106 offset.clamp(nearest, furthest)
1107 };
1108
1109 Some(taffy::Point {
1110 x: axis(
1111 inset(&insets.left, port_width),
1112 inset(&insets.right, port_width),
1113 base.x,
1114 size.width,
1115 port_x,
1116 port_width,
1117 block_x,
1118 block_width,
1119 ),
1120 y: axis(
1121 inset(&insets.top, port_height),
1122 inset(&insets.bottom, port_height),
1123 base.y,
1124 size.height,
1125 port_y,
1126 port_height,
1127 block_y,
1128 block_height,
1129 ),
1130 })
1131 }
1132
1133 /// The nearest ancestor of `node_id` that scrolls its content, or `None`
1134 /// when nothing between it and the root does and the viewport is the
1135 /// scrollport.
1136 ///
1137 /// The root element is excluded deliberately: per the CSS overflow
1138 /// propagation rules its overflow belongs to the viewport, which is also
1139 /// why `scroll_node_by_has_changed` forwards a scroll that reaches it.
1140 fn nearest_scrollport(&self, node_id: NodeId) -> Option<NodeId> {
1141 use style::computed_values::overflow_x::T as Overflow;
1142
1143 let root_id = self.try_root_element()?.id;
1144 let mut current = self.nodes.get(node_id)?.layout_parent.get();
1145 while let Some(id) = current {
1146 if id == root_id {
1147 return None;
1148 }
1149 let ancestor = self.nodes.get(id)?;
1150 if let Some(styles) = ancestor.primary_styles() {
1151 let scrolls = |overflow| {
1152 matches!(
1153 overflow,
1154 Overflow::Scroll | Overflow::Auto | Overflow::Hidden
1155 )
1156 };
1157 if scrolls(styles.clone_overflow_x()) || scrolls(styles.clone_overflow_y()) {
1158 return Some(id);
1159 }
1160 }
1161 current = ancestor.layout_parent.get();
1162 }
1163 None
1164 }
1165
1166 /// Give each held fixed layer the offset that cancels its hoist.
1167 ///
1168 /// Paint draws a hoisted child at its stacking context root's origin, plus
1169 /// the recorded offset, plus the node's own layout location — and that
1170 /// location is relative to the root element, because the hoist made the
1171 /// root its layout parent. So the offset has to carry the difference
1172 /// between the two origins, or a background mounted with `inset: 0` lands
1173 /// wherever its isolate happens to sit rather than over the viewport.
1174 ///
1175 /// Separate from `flush_styles_to_layout`, which decides *which* context
1176 /// holds the layer: that runs before taffy, when every absolute position is
1177 /// still zero.
1178 pub(crate) fn correct_hoisted_fixed_positions(&mut self) {
1179 if self.hoisted_fixed_parents.is_empty() {
1180 return;
1181 }
1182 let root_id = self.root_element().id;
1183 let root_abs = self.nodes[root_id].absolute_position(0.0, 0.0);
1184
1185 let placements: Vec<(NodeId, NodeId)> = self
1186 .hoisted_fixed_parents
1187 .iter()
1188 .filter_map(|(&node_id, &origin)| {
1189 let host = self.nearest_stacking_context_ancestor(origin)?;
1190 (host != root_id).then_some((node_id, host))
1191 })
1192 .collect();
1193
1194 for (node_id, host) in placements {
1195 let host_abs = self.nodes[host].absolute_position(0.0, 0.0);
1196 let host_scroll = *self.nodes[host].scroll_offset();
1197 let Some(mut context) = self.nodes[host].stacking_context.take() else {
1198 continue;
1199 };
1200 for child in context.children.iter_mut() {
1201 if child.node_id == node_id {
1202 child.position = taffy::Point {
1203 x: root_abs.x - host_abs.x + host_scroll.x as f32,
1204 y: root_abs.y - host_abs.y + host_scroll.y as f32,
1205 };
1206 }
1207 }
1208 context.compute_content_size(self);
1209 self.nodes[host].stacking_context = Some(context);
1210 }
1211 }
1212
1213 /// Recompute hoisted paint offsets from the layout that just finished.
1214 ///
1215 /// Stacking contexts are assembled while styles are flushed into Taffy,
1216 /// before Taffy computes this frame's boxes. Accumulating ancestor
1217 /// locations during that flush therefore reads zeroes on the first frame
1218 /// and previous-frame positions after relayout. The next unrelated
1219 /// restyle rebuilds the same context from current boxes, which made a
1220 /// transformed flower repair itself on its first hover.
1221 ///
1222 /// A hoisted child is painted from the context host, then by this offset,
1223 /// then by its own layout location. Its layout parent's document position
1224 /// relative to the host is therefore the exact offset needed here. Fixed
1225 /// children are the exception: their box tree was deliberately reparented
1226 /// to the root and [`Self::correct_hoisted_fixed_positions`] restores their
1227 /// authored stacking-context offset separately.
1228 pub(crate) fn resolve_hoisted_positions(&mut self) {
1229 self.resolve_abspos_positions();
1230 let nodes = &self.nodes;
1231 self.hoisted_position_hosts
1232 .retain(|id| nodes.contains_key(*id));
1233 let hosts: Vec<NodeId> = self.hoisted_position_hosts.iter().copied().collect();
1234
1235 for host in hosts {
1236 let host_position = self.nodes[host].absolute_position(0.0, 0.0);
1237 let Some(mut context) = self.nodes[host].stacking_context.take() else {
1238 continue;
1239 };
1240
1241 // A stacking context is assembled during a style flush and then
1242 // outlives it. `flush_styles_to_layout` returns early on a
1243 // `display: none` subtree, so a host hidden after its list was
1244 // built never rebuilds that list, while this pass walks every node
1245 // that has a context, hidden or not. Removing one of the listed
1246 // children left a freed key behind, and the next resolve panicked
1247 // with "invalid SlotMap key used" rather than painting one frame
1248 // stale. Prune here, where the whole list is already in hand.
1249 if context
1250 .children
1251 .iter()
1252 .any(|child| !self.nodes.contains_key(child.node_id))
1253 {
1254 context
1255 .children
1256 .retain(|child| self.nodes.contains_key(child.node_id));
1257 context.negative_z_count = context
1258 .children
1259 .iter()
1260 .take_while(|child| child.z_index < 0)
1261 .count() as u32;
1262 }
1263
1264 for child in context.children.iter_mut() {
1265 let node = &self.nodes[child.node_id];
1266 if self.fixed_nodes.contains(&child.node_id) {
1267 continue;
1268 }
1269 let child_position = node.absolute_position(0.0, 0.0);
1270 let child_layout_position = node.final_layout().location;
1271 let host_scroll = self.nodes[host].scroll_offset();
1272 child.position = taffy::Point {
1273 x: child_position.x - host_position.x - child_layout_position.x
1274 + host_scroll.x as f32,
1275 y: child_position.y - host_position.y - child_layout_position.y
1276 + host_scroll.y as f32,
1277 };
1278 }
1279
1280 context.compute_content_size(self);
1281 self.nodes[host].stacking_context = Some(context);
1282 }
1283 }
1284
1285 /// Turn each hoisted child's clipping ancestors into rectangles paint can
1286 /// use, relative to the origin of the stacking context it paints in.
1287 ///
1288 /// Separate from `flush_styles_to_layout`, which decides *which* ancestors
1289 /// clip: that runs before taffy, when every box is still zero-sized, so
1290 /// reading a size there produced an empty clip and made hoisted content
1291 /// disappear entirely rather than merely escape.
1292 pub(crate) fn resolve_hoisted_clips(&mut self) {
1293 if self.hoisted_clip_hosts.is_empty() {
1294 return;
1295 }
1296
1297 // Retain registrations from skipped subtrees across style flushes.
1298 let nodes = &self.nodes;
1299 self.hoisted_clip_hosts.retain(|id| nodes.contains_key(*id));
1300 let hosts: Vec<NodeId> = self.hoisted_clip_hosts.iter().copied().collect();
1301 for host in hosts {
1302 let Some(mut context) = self.nodes[host].stacking_context.take() else {
1303 continue;
1304 };
1305 let host_position = self.nodes[host].absolute_position(0.0, 0.0);
1306
1307 for child in context.children.iter_mut() {
1308 child.clips.clear();
1309 child.clips.reserve(child.clip_ancestors.len());
1310 for &clipper in child.clip_ancestors.iter() {
1311 let node = &self.nodes[clipper];
1312 // The clip is the clipping box's own border box, so its
1313 // own scroll offset does not enter into it. Ancestor
1314 // scrolling does, and `absolute_position` applies that.
1315 let position = node.absolute_position(0.0, 0.0);
1316 let layout = node.final_layout();
1317 // Paint and hit testing use the host's unscrolled content
1318 // coordinates, applying its scroll offset once afterwards.
1319 let host_scroll = self.nodes[host].scroll_offset();
1320 let left = position.x - host_position.x + host_scroll.x as f32;
1321 let top = position.y - host_position.y + host_scroll.y as f32;
1322 // The padding box, matching what paint clips content to.
1323 child.clips.push(taffy::Rect {
1324 left: left + layout.border.left,
1325 top: top + layout.border.top,
1326 right: left + layout.size.width - layout.border.right,
1327 bottom: top + layout.size.height - layout.border.bottom,
1328 });
1329 }
1330 }
1331
1332 self.nodes[host].stacking_context = Some(context);
1333 }
1334 }
1335
1336 /// Scrolling can move containing blocks without a style or layout pass.
1337 /// Update shared box locations before refreshing paint offsets and clips.
1338 pub(crate) fn resolve_positioned_scroll(&mut self) {
1339 self.resolve_sticky_positions();
1340 self.resolve_fixed_positions();
1341 self.resolve_hoisted_positions();
1342 self.correct_hoisted_fixed_positions();
1343 self.resolve_hoisted_clips();
1344 if let Some(root) = self.try_root_element().map(|node| node.id) {
1345 self.resolve_transforms(root);
1346 }
1347 }
1348
1349 pub fn resolve_deferred_tasks(&mut self) {
1350 let mut deferred_construction_nodes = std::mem::take(&mut self.deferred_construction_nodes);
1351
1352 // Deduplicate deferred tasks by node_id to avoid redundant work
1353 deferred_construction_nodes.sort_unstable_by_key(|task| task.node_id);
1354 deferred_construction_nodes.dedup_by_key(|task| task.node_id);
1355
1356 #[cfg(feature = "parallel-construct")]
1357 let iter = deferred_construction_nodes.into_par_iter();
1358 #[cfg(not(feature = "parallel-construct"))]
1359 let iter = deferred_construction_nodes.into_iter();
1360
1361 let results: Vec<ConstructionTaskResult> = iter
1362 .map(|task: ConstructionTask| match task.data {
1363 ConstructionTaskData::InlineLayout(mut layout) => {
1364 #[cfg(feature = "parallel-construct")]
1365 let mut layout_ctx = LAYOUT_CTX
1366 .take()
1367 .unwrap_or_else(|| Box::new(LayoutContext::new()));
1368 #[cfg(feature = "parallel-construct")]
1369 let layout_ctx_mut = &mut layout_ctx;
1370
1371 #[cfg(feature = "parallel-construct")]
1372 let mut font_ctx = self
1373 .thread_font_contexts
1374 .get_or(|| RefCell::new(Box::new(self.font_ctx.lock().unwrap().clone())))
1375 .borrow_mut();
1376 #[cfg(feature = "parallel-construct")]
1377 let font_ctx_mut = &mut *font_ctx;
1378
1379 #[cfg(not(feature = "parallel-construct"))]
1380 let layout_ctx_mut = &mut self.layout_ctx;
1381 #[cfg(not(feature = "parallel-construct"))]
1382 let font_ctx_mut = &mut *self.font_ctx.lock().unwrap();
1383
1384 layout.content_widths = None;
1385 build_inline_layout_into(
1386 &self.nodes,
1387 layout_ctx_mut,
1388 font_ctx_mut,
1389 &mut layout,
1390 self.viewport.scale(),
1391 task.node_id,
1392 );
1393
1394 #[cfg(feature = "parallel-construct")]
1395 {
1396 LAYOUT_CTX.set(Some(layout_ctx));
1397 }
1398
1399 // If layout doesn't contain any inline boxes, then it is safe to populate the content_widths
1400 // cache during this parallelized stage.
1401 // if layout.layout.inline_boxes().is_empty() {
1402 // layout.content_widths();
1403 // }
1404
1405 ConstructionTaskResult {
1406 node_id: task.node_id,
1407 data: ConstructionTaskResultData::InlineLayout(layout),
1408 }
1409 }
1410 })
1411 .collect();
1412
1413 for result in results {
1414 match result.data {
1415 ConstructionTaskResultData::InlineLayout(layout) => {
1416 self.abspos_layout_dirty = true;
1417 // The node and every layout ancestor. The shaped layout
1418 // that lands here has not been broken into lines yet, and
1419 // an ancestor still holding a cached layout never descends,
1420 // so clearing this node alone leaves the fresh unbroken
1421 // layout in place with nothing to break it. Non-atomic
1422 // inline elements then report geometry from a single line
1423 // as wide as the whole paragraph.
1424 //
1425 // `layout_parent`, not `parent`: taffy walks the layout
1426 // tree, and anonymous blocks make the two chains differ.
1427 let mut current = Some(result.node_id);
1428 while let Some(node_id) = current {
1429 let node = &mut self.nodes[node_id];
1430 node.cache_release();
1431 node.insert_damage(RestyleDamage::RECALCULATE_OVERFLOW);
1432 current = node.layout_parent.get();
1433 }
1434 self.nodes[result.node_id]
1435 .element_data_mut()
1436 .unwrap()
1437 .inline_layout_data = Some(layout);
1438 }
1439 }
1440 }
1441
1442 self.deferred_construction_nodes.clear();
1443 }
1444
1445 /// Walk the nodes now that they're properly styled and transfer their styles to the taffy style system
1446 ///
1447 /// TODO: update taffy to use an associated type instead of slab key
1448 /// TODO: update taffy to support traited styles so we don't even need to rely on taffy for storage
1449 pub fn resolve_layout(&mut self) {
1450 // Preserve corrected boxes, fixed parent slots, caches and positional
1451 // offsets when neither style/tree changes nor layout damage occurred.
1452 if self.incremental_layout && !self.abspos_layout_dirty && !self.abspos_candidates_dirty {
1453 return;
1454 }
1455 let size = self.stylist.device().au_viewport_size();
1456
1457 let available_space = taffy::Size {
1458 width: AvailableSpace::Definite(size.width.to_f32_px()),
1459 height: AvailableSpace::Definite(size.height.to_f32_px()),
1460 };
1461
1462 let root_element_id = crate::taffy_node_id(self.root_element().id);
1463
1464 // println!("\n\nRESOLVE LAYOUT\n===========\n");
1465
1466 let root_id = crate::dom_node_id(root_element_id);
1467 let previous_content_size = self.nodes[root_id].unrounded_layout().content_size;
1468 self.abspos_normal_layout = false;
1469 let fixed_parents = self.restore_fixed_layout_parents();
1470 taffy::compute_root_layout(self, root_element_id, available_space);
1471 // A cached root output predates the correction of descendant extents.
1472 // Its descendant boxes remain corrected; preserve the root's extent too.
1473 if !self.abspos_normal_layout {
1474 self.nodes[root_id].unrounded_layout_mut().content_size = previous_content_size;
1475 }
1476 let placements = self.resolve_abspos_layout(
1477 crate::dom_node_id(root_element_id),
1478 taffy::Size {
1479 width: size.width.to_f32_px(),
1480 height: size.height.to_f32_px(),
1481 },
1482 );
1483 self.finish_fixed_layout_parents(fixed_parents);
1484 taffy::round_layout(self, root_element_id);
1485 self.finish_abspos_placements(placements);
1486 self.abspos_layout_dirty = false;
1487
1488 // Rounding rewrites every location from taffy's own output, discarding
1489 // the sticky and fixed displacements written into them along with
1490 // everything else. Forgetting them here is what keeps
1491 // `resolve_sticky_positions` and `resolve_fixed_positions` able to
1492 // treat what they hold as "what is currently baked into a box".
1493 self.sticky_offsets.clear();
1494 self.fixed_scroll_offset = crate::Point::ZERO;
1495
1496 // Table rows and row groups are flattened into a grid of cells and
1497 // never reach Taffy, so nothing wrote a layout for them at all. Describe
1498 // each from the cells it holds, after rounding: `final_layout` is what
1499 // every geometry query reads and the rounding pass is what fills it, so
1500 // doing this any earlier reads cells that are still zero.
1501 self.assign_table_row_layouts();
1502
1503 // Fixed locations already use the viewport, or their transformed
1504 // containing block. No document-origin cancellation is needed here.
1505
1506 // println!("\n\n");
1507 // taffy::print_tree(self, root_node_id)
1508 }
1509}
1510
1511#[cfg(test)]
1512mod tests {
1513 use crate::{BaseDocument, DocumentConfig};
1514
1515 #[test]
1516 fn resolving_for_inspection_cannot_rewind_the_animation_clock() {
1517 let mut document = BaseDocument::new(DocumentConfig::default());
1518 document.resolve(2.5);
1519 document.resolve(0.0);
1520 assert_eq!(document.last_resolve_animation_time, 2.5);
1521 }
1522}