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