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 style::values::computed::Rotate;
28use style::values::generics::transform::{Scale, Translate};
29use taffy::AvailableSpace;
30
31use crate::{
32 BaseDocument,
33 events::ScrollAnimationState,
34 layout::{
35 construct::{
36 ConstructionTask, ConstructionTaskData, ConstructionTaskResult,
37 ConstructionTaskResultData, LayoutChildren, build_inline_layout_into,
38 collect_layout_children,
39 },
40 damage::{ALL_DAMAGE, CONSTRUCT_BOX, CONSTRUCT_DESCENDENT, CONSTRUCT_FC},
41 },
42 node::TextBrush,
43};
44
45impl BaseDocument {
46 /// Pull every scroll offset back inside the content it scrolls.
47 ///
48 /// Scrolling clamps against the extent at the time of the gesture, and
49 /// nothing re-checked it afterwards. So any layout that made a scroller's
50 /// content *shorter* left the offset beyond the new end, and the view
51 /// stayed parked in space the content no longer reaches: dismiss a panel
52 /// while scrolled to the bottom and its height is simply gone from under
53 /// you, leaving a band of nothing between the last content and the edge.
54 /// Far enough past the end and there is nothing left to see at all.
55 ///
56 /// Done after layout, which is the only point at which the new extents are
57 /// known, and cheap: offsets are almost always zero.
58 fn clamp_scroll_offsets(&mut self) {
59 for (_, node) in self.nodes.iter_mut() {
60 // The accessor panics on node kinds that have no scroll offset, so
61 // ask the data first rather than every node in the tree.
62 let Some(offset) = node
63 .data
64 .downcast_element()
65 .map(|element| element.scroll_offset)
66 else {
67 continue;
68 };
69 if offset.x == 0.0 && offset.y == 0.0 {
70 continue;
71 }
72 let max_x = f64::from(node.final_layout().scroll_width()).max(0.0);
73 let max_y = f64::from(node.final_layout().scroll_height()).max(0.0);
74 let clamped = node.scroll_offset_mut();
75 clamped.x = offset.x.clamp(0.0, max_x);
76 clamped.y = offset.y.clamp(0.0, max_y);
77 }
78 }
79
80 /// Re-break any inline layout whose lines belong to a pass other than the
81 /// one that decided its box.
82 ///
83 /// Taffy performs layout under min-content and max-content constraints
84 /// while sizing a box, and every one of those passes breaks the same parley
85 /// layout the screen reads from. Whichever ran last is what gets painted.
86 /// That is usually the real layout, and when the final pass is answered
87 /// from the taffy cache it is not: `compute_inline_layout` never runs
88 /// again, and the trial break stays. Reported as "1st load is fucked" and
89 /// measured on a live transcript as paragraphs broken at 164px inside a
90 /// 1,426px box, 39 lines of one or two words each.
91 ///
92 /// Cheap by construction: it compares two floats per inline root and
93 /// re-breaks only the ones that disagree, which in a settled document is
94 /// none of them.
95 /// Returns whether any repair changed a layout's height, which means the
96 /// boxes taffy sized are now wrong and layout has to run again.
97 fn repair_inline_line_breaks(&mut self) -> bool {
98 let scale = self.viewport.scale();
99
100 let mut wrong = Vec::new();
101 for (node_id, node) in self.nodes.iter() {
102 let Some(inline) = node
103 .data
104 .downcast_element()
105 .and_then(|element| element.inline_layout_data.as_ref())
106 else {
107 continue;
108 };
109 // The *unrounded* layout, which is the width the layout pass
110 // broke at. `final_layout` is rounded to whole pixels, and half a
111 // pixel of rounding-down is enough to wrap a label that exactly
112 // fit: "125.1k / 200.0k ctx · 63%" came back on two lines.
113 let layout = node.unrounded_layout();
114 let content_width = (layout.size.width
115 - layout.padding.left
116 - layout.padding.right
117 - layout.border.left
118 - layout.border.right)
119 .max(0.0)
120 * scale;
121 // Half a device pixel: below that the break is identical and
122 // re-running it would cost a frame to change nothing.
123 if inline
124 .laid_out_at
125 .is_none_or(|broken_at| (broken_at - content_width).abs() > 0.5)
126 {
127 wrong.push((node_id, content_width));
128 }
129 }
130
131 let mut changed_height = false;
132 for (node_id, content_width) in wrong {
133 // Breaking discards the alignment the layout pass applied, so it
134 // has to go back on: without it every centred or right-aligned
135 // paragraph this touches would silently come back left-aligned.
136 let alignment = self.nodes[node_id]
137 .primary_styles()
138 .map(|style| {
139 use parley::layout::Alignment;
140 use style::values::specified::TextAlignKeyword;
141 match style.clone_text_align() {
142 TextAlignKeyword::Start => Alignment::Start,
143 TextAlignKeyword::Left | TextAlignKeyword::MozLeft => Alignment::Left,
144 TextAlignKeyword::Right | TextAlignKeyword::MozRight => Alignment::Right,
145 TextAlignKeyword::Center | TextAlignKeyword::MozCenter => Alignment::Center,
146 TextAlignKeyword::Justify => Alignment::Justify,
147 TextAlignKeyword::End => Alignment::End,
148 }
149 })
150 .unwrap_or(parley::layout::Alignment::Start);
151
152 let Some(inline) = self.nodes[node_id]
153 .data
154 .downcast_element_mut()
155 .and_then(|element| element.inline_layout_data.as_mut())
156 else {
157 continue;
158 };
159 inline.layout.break_all_lines(Some(content_width));
160 inline.layout.align(
161 alignment,
162 parley::layout::AlignmentOptions {
163 align_when_overflowing: false,
164 },
165 );
166 inline.laid_out_at = Some(content_width);
167
168 // Any repair at all invalidates the boxes around it, not just one
169 // whose parley height moved. The box was sized by a pass that broke
170 // these lines differently, and its height was accumulated into
171 // every ancestor's content size on the way up. Comparing parley
172 // heights before and after missed that: the layout being repaired
173 // is not the one the box was sized from, so it can come out the
174 // same height while the box is still wrong. Measured live as a
175 // transcript whose content ran 1,062px past the extent it reported,
176 // so it could not scroll to its own last message.
177 changed_height = true;
178 self.nodes[node_id].insert_damage(RestyleDamage::RELAYOUT);
179 }
180
181 changed_height
182 }
183
184 /// Restyle the tree and then relayout it
185 pub fn resolve(&mut self, current_time_for_animations: f64) {
186 if current_time_for_animations.is_finite() {
187 self.last_resolve_animation_time = self
188 .last_resolve_animation_time
189 .max(current_time_for_animations);
190 }
191 let current_time_for_animations = self.last_resolve_animation_time;
192
193 if TDocument::as_node(&self.root_node())
194 .first_element_child()
195 .is_none()
196 {
197 #[cfg(feature = "tracing")]
198 tracing::warn!("No DOM - not resolving");
199 return;
200 }
201
202 // Process messages that have been sent to our message channel (e.g. loaded resource)
203 self.handle_messages();
204
205 self.resolve_scroll_animation();
206
207 // Retain completed activity entries so an initially visible scrollbar
208 // stays faded after its first interaction. Only removed nodes need to
209 // shed their entry.
210 let nodes = &self.nodes;
211 self.scrollbar_activity
212 .retain(|node_id, _| nodes.contains_key(*node_id));
213
214 let root_node_id = self.root_element().id;
215 #[cfg(feature = "log-phase-times")]
216 let mut timer =
217 debug_timer::RealDebugTimer::init_if(blitz_traits::profiling::deep_profiling_enabled());
218 #[cfg(not(feature = "log-phase-times"))]
219 let mut timer = debug_timer::DummyDebugTimer::init();
220 #[cfg(feature = "log-phase-times")]
221 crate::layout::layout_counters::begin(blitz_traits::profiling::deep_profiling_enabled());
222
223 // Compute the shadow DOM flattened tree (shadow-root composition and
224 // <slot> distribution). This must happen *before* style resolution so
225 // that Stylo traverses the composed (flattened) tree and styles shadow
226 // content, and before box construction consumes it.
227 #[cfg(feature = "shadow-dom")]
228 {
229 self.compute_flattened_trees();
230 timer.record_time("shadow");
231 }
232
233 // we need to resolve stylist first since it will need to drive our layout bits
234 self.resolve_stylist(current_time_for_animations);
235 timer.record_time("style");
236
237 self.paint_damage.begin_resolve();
238
239 // Propagate damage flags (from mutation and restyles) up and down the tree
240 if self.incremental_layout {
241 self.propagate_damage_flags(root_node_id, RestyleDamage::empty());
242 timer.record_time("damage");
243 }
244 // Anything after this point sees propagated damage, in which every
245 // ancestor up to the root is marked. Recording repaints from there
246 // would describe every frame as a full-frame repaint.
247 self.paint_damage.end_propagation();
248
249 // Fix up tree for layout (insert anonymous blocks as necessary, etc)
250 self.resolve_layout_children();
251 timer.record_time("construct");
252
253 self.resolve_deferred_tasks();
254 timer.record_time("pconstruct");
255
256 self.hoist_fixed_position_nodes();
257 timer.record_time("hoist");
258
259 // Merge stylo into taffy
260 self.flush_styles_to_layout(root_node_id);
261 timer.record_time("flush");
262
263 // Next we resolve layout with the data resolved by stlist
264 //
265 // Caught, under `BLITZ_TRACE_LAYOUT_PANIC=1` only, so the markup that
266 // killed layout can be printed before the process goes. The panic hook
267 // that names the element runs without the document and can only give an
268 // id and a class list; the id is worthless once the process is gone,
269 // and a class list is not markup you can put in a test. This is the one
270 // place that still holds `&mut self` when layout fails, so it is the
271 // only place the subtree can be serialized. The panic is resumed
272 // immediately: nothing here makes a failed layout survivable.
273 #[cfg(not(target_arch = "wasm32"))]
274 if crate::layout::layout_panic_probe::enabled() {
275 let attempt =
276 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.resolve_layout()));
277 if let Err(payload) = attempt {
278 if let Some(node_id) = crate::layout::layout_panic_probe::innermost_node() {
279 if let Some(node) = self.nodes.get(node_id) {
280 eprintln!(
281 "[blitz-layout-panic] markup of node {node_id}:\n{}",
282 node.outer_html_pretty()
283 );
284 }
285 }
286 std::panic::resume_unwind(payload);
287 }
288 } else {
289 self.resolve_layout();
290 }
291 #[cfg(target_arch = "wasm32")]
292 self.resolve_layout();
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_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.trace_escaped_inline_fragments();
323
324 // Resolve transforms
325 self.resolve_transforms(root_node_id);
326 timer.record_time("transform");
327
328 // Boxes are final here, which is what the geometry half compares. It
329 // runs before the clearing loop below only because both walk the node
330 // list and doing them together saves nothing: this one needs `&nodes`
331 // while that one needs `&mut`.
332 if self.paint_damage.is_enabled() {
333 let mut tracker = std::mem::take(&mut self.paint_damage);
334 tracker.capture(&self.nodes);
335 self.paint_damage = tracker;
336 timer.record_time("paint_damage");
337 }
338
339 // Clear all damage and dirty flags
340 if self.incremental_layout {
341 for (_, node) in self.nodes.iter_mut() {
342 node.clear_damage_mut();
343 node.unset_dirty_descendants();
344 }
345 timer.record_time("c_damage");
346 }
347
348 // Re-resolve the hover node from the pointer position against the fresh
349 // layout. This must run *after* the damage/dirty flags are cleared
350 // above, so that the restyle hint and ancestor `dirty_descendants`
351 // flags set by any resulting hover change survive into the next resolve
352 // pass (the clearing loop would otherwise wipe them). Any resulting
353 // restyle is picked up on the next resolve pass; a redraw is requested
354 // if the hovered node actually changes.
355 self.refresh_hover();
356
357 let mut subdoc_animation_pacing = crate::document::AnimationPacing::Idle;
358 for &node_id in &self.sub_document_nodes {
359 let node = &mut self.nodes[node_id];
360 let size = node.final_layout().size;
361 if let Some(mut sub_doc) = node.subdoc_mut().map(|doc| doc.inner_mut()) {
362 // Set viewport
363 // viewport_mut handles change detection. So we just unconditionally set the values;
364 let mut sub_viewport = sub_doc.viewport_mut();
365 sub_viewport.hidpi_scale = self.viewport.hidpi_scale;
366 sub_viewport.zoom = self.viewport.zoom;
367 sub_viewport.color_scheme = self.viewport.color_scheme;
368
369 let viewport_scale = self.viewport.scale();
370 sub_viewport.window_size = (
371 (size.width * viewport_scale) as u32,
372 (size.height * viewport_scale) as u32,
373 );
374 drop(sub_viewport);
375
376 sub_doc.resolve(current_time_for_animations);
377
378 subdoc_animation_pacing = subdoc_animation_pacing.max(sub_doc.animation_pacing());
379 }
380 }
381 self.subdoc_animation_pacing = subdoc_animation_pacing;
382 timer.record_time("subdocs");
383
384 // Printed with the phases so a single line says both how long layout
385 // took and how much of the tree it touched. Without the counts the
386 // timings cannot distinguish a few slow nodes from a cache miss across
387 // the document, and those need opposite fixes.
388 #[cfg(feature = "log-phase-times")]
389 {
390 // The offenders are read, and the message built, only when a sink
391 // is configured: the counters are cheap to keep and expensive to
392 // describe, and this feature now travels with a shipped binary.
393 // Draining, though, is unconditional — `layout_counters::last()` is
394 // what the benchmarks read, and counts that are never taken keep
395 // accumulating across resolves.
396 let describe = timer.is_logging();
397 if describe {
398 // Named before the counters are drained, and only when the pass
399 // was expensive enough to be worth looking at.
400 let offenders = crate::layout::layout_counters::worst_offenders(6);
401 if offenders.first().is_some_and(|(_, count)| *count > 8) {
402 let described: Vec<String> = offenders
403 .iter()
404 .map(|(id, count)| {
405 let tag = self
406 .nodes
407 .get(*id)
408 .and_then(|node| node.element_data())
409 .map(|element| element.name.local.to_string())
410 .unwrap_or_else(|| "?".to_string());
411 let display = self
412 .nodes
413 .get(*id)
414 .map(|node| format!("{:?}", node.style().display))
415 .unwrap_or_default();
416 format!("{id:?}:{tag}({display})x{count}")
417 })
418 .collect();
419 debug_timer::log_line(&format!(" layout hotspots: {}\n", described.join(" ")));
420 }
421 }
422 let counts = crate::layout::layout_counters::take();
423 if describe {
424 let total_nodes = self.nodes.len();
425 let hit_rate = if counts.lookups > 0 {
426 (counts.hits as f64 / counts.lookups as f64) * 100.0
427 } else {
428 0.0
429 };
430 timer.print_times(&format!(
431 "Resolve({}) [computed {} over {} distinct of {total_nodes} nodes, \
432 cache {}/{} hits {hit_rate:.0}%, {} cleared]: ",
433 self.id(),
434 counts.computed,
435 counts.distinct,
436 counts.hits,
437 counts.lookups,
438 counts.caches_cleared,
439 ));
440 }
441 }
442 #[cfg(not(feature = "log-phase-times"))]
443 timer.print_times(&format!("Resolve({}): ", self.id()));
444 }
445
446 fn resolve_transforms(&mut self, node_id: NodeId) -> Rect {
447 if !self.nodes.contains_key(node_id) {
448 return Rect::ZERO;
449 }
450
451 if !self.nodes[node_id]
452 .damage()
453 .map(|d| d.contains(style::selector_parser::RestyleDamage::RECALCULATE_OVERFLOW))
454 .unwrap_or(false)
455 {
456 return *self.nodes[node_id].scrollable_overflow();
457 }
458
459 let scale = self.viewport.scale_f64();
460
461 let transform = self.nodes[node_id].set_transform(scale as f32);
462
463 let w = self.nodes[node_id].final_layout().size.width as f64 * scale;
464 let h = self.nodes[node_id].final_layout().size.height as f64 * scale;
465 let mut overflow = Rect::new(0.0, 0.0, w, h);
466
467 let layout_children = std::mem::take(self.nodes[node_id].layout_children.get_mut());
468
469 if let Some(ref children) = layout_children {
470 for &child_id in children {
471 let child_rect_in_self = self.resolve_transforms(child_id);
472 overflow = overflow.union(child_rect_in_self);
473 }
474 }
475 if let Some(before) = self.nodes[node_id].before() {
476 let child_rect_in_self = self.resolve_transforms(before);
477 overflow = overflow.union(child_rect_in_self);
478 }
479 if let Some(after) = self.nodes[node_id].after() {
480 let child_rect_in_self = self.resolve_transforms(after);
481 overflow = overflow.union(child_rect_in_self);
482 }
483
484 // Text overflows too, and only layout *children* were counted above.
485 //
486 // Glyph runs are not nodes, so a `white-space: nowrap` line wider than
487 // its box left `scrollable_overflow` exactly equal to that box. Paint
488 // skips its clip layer when the overflow rect fits the border box —
489 // most `overflow-hidden` wrappers really do clip nothing, and a layer
490 // is the most expensive thing in a frame — so the one case that needed
491 // the clip was the one case that reported it was unnecessary. A
492 // truncated tab title painted straight through the close button beside
493 // it, a branch name through the chip after it, and a transcript line
494 // under the cost readout: measured here as a 150px box painting its
495 // text out to x=354.
496 if let Some(inline_layout) = self.nodes[node_id]
497 .data
498 .downcast_element()
499 .and_then(|element| element.inline_layout_data.as_ref())
500 {
501 // Already device pixels: parley is handed the scaled size, so
502 // scaling again doubled every inline root's overflow at 2x and
503 // inflated its hit area with it.
504 let text_width = inline_layout.layout.width() as f64;
505 let text_height = inline_layout.layout.height() as f64;
506 overflow = overflow.union(Rect::new(0.0, 0.0, text_width, text_height));
507 }
508
509 *self.nodes[node_id].scrollable_overflow_mut() = overflow;
510 *self.nodes[node_id].layout_children.get_mut() = layout_children;
511
512 let scaled_x = self.nodes[node_id].final_layout().location.x as f64 * scale;
513 let scaled_y = self.nodes[node_id].final_layout().location.y as f64 * scale;
514
515 let full = if let Some(t) = transform {
516 Affine::translate((scaled_x, scaled_y)) * t
517 } else {
518 Affine::translate((scaled_x, scaled_y))
519 };
520
521 full.transform_rect_bbox(overflow)
522 }
523
524 pub fn resolve_scroll_animation(&mut self) {
525 match &mut self.scroll_animation {
526 ScrollAnimationState::Fling(fling_state) => {
527 let time_ms = SystemTime::now()
528 .duration_since(UNIX_EPOCH)
529 .unwrap()
530 .as_millis() as u64 as f64;
531
532 let time_diff_ms = time_ms - fling_state.last_seen_time;
533
534 // 0.95 @ 60fps normalized to actual frame times
535 let deceleration = 1.0 - ((0.05 / 16.66666) * time_diff_ms);
536
537 fling_state.x_velocity *= deceleration;
538 fling_state.y_velocity *= deceleration;
539 fling_state.last_seen_time = time_ms;
540 let fling_state = fling_state.clone();
541
542 let dx = fling_state.x_velocity * time_diff_ms;
543 let dy = fling_state.y_velocity * time_diff_ms;
544
545 self.scroll_by(Some(fling_state.target), dx, dy, &mut |_| {});
546 if fling_state.x_velocity.abs() < 0.1 && fling_state.y_velocity.abs() < 0.1 {
547 self.scroll_animation = ScrollAnimationState::None;
548 }
549 }
550 ScrollAnimationState::None => {
551 // Do nothing
552 }
553 }
554 }
555
556 /// Ensure that the layout_children field is populated for all nodes
557 pub fn resolve_layout_children(&mut self) {
558 resolve_layout_children_recursive(self, self.root_node().id);
559
560 fn resolve_layout_children_recursive(doc: &mut BaseDocument, node_id: NodeId) {
561 // Anonymous blocks and pseudo-elements can be removed from the slab
562 // between render passes. Bail out rather than panicking on a stale key.
563 if doc.nodes.get(node_id).is_none() {
564 return;
565 }
566
567 let mut damage = doc.nodes[node_id].damage().unwrap_or(ALL_DAMAGE);
568 let _flags = doc.nodes[node_id].flags;
569
570 // A hidden subtree keeps the boxes it already has.
571 //
572 // `display: none` means "do not lay this out", not "forget what you
573 // know about it". Collecting layout children for a hidden container
574 // yields an empty list, so hiding a pane used to discard every box
575 // and every shaped inline layout beneath it, and revealing it built
576 // all of them again from the DOM. In an application that retains
577 // its tabs and toggles them by class, that is the entire cost of a
578 // tab switch, paid again on every switch: measured on six retained
579 // panes of a real project tab, a *re-reveal* cost exactly what the
580 // first reveal cost, 46,526 layout computations either way.
581 //
582 // Damage is deliberately left in place rather than cleared. Content
583 // that changes while hidden still carries its damage to the reveal,
584 // where the normal path reconstructs precisely what changed.
585 if doc.incremental_layout
586 && doc.nodes[node_id].is_display_none()
587 && doc.nodes[node_id].layout_children.borrow().is_some()
588 {
589 return;
590 }
591
592 // A node that has never been constructed has no boxes to keep, and
593 // no damage either once its styles survive being hidden: a pane
594 // that was hidden before it was ever shown reaches its reveal with
595 // valid styles, nothing marked dirty, and nothing to lay out. It
596 // used to be rescued by stylo discarding those styles. Ask the
597 // boxes instead of the damage.
598 let never_constructed = doc.nodes[node_id].layout_children.borrow().is_none();
599
600 if !doc.incremental_layout
601 || never_constructed
602 || damage.intersects(CONSTRUCT_FC | CONSTRUCT_BOX)
603 {
604 //} || flags.contains(NodeFlags::IS_INLINE_ROOT) {
605
606 // Deallocate the anonymous blocks created for this node in the
607 // previous construction round. They live only in the slab, so
608 // reconstructing without freeing them would leak a slab entry per
609 // anonymous block per reconstruction.
610 let old_anonymous_blocks = std::mem::take(&mut doc.nodes[node_id].anonymous_blocks);
611 for anon_id in old_anonymous_blocks {
612 doc.deallocate_anonymous_block(anon_id);
613 }
614
615 let mut collected = LayoutChildren::default();
616 collect_layout_children(doc, node_id, &mut collected);
617 let layout_children = collected.children;
618 doc.nodes[node_id].anonymous_blocks = collected.anonymous_blocks;
619
620 // Recurse into newly collected layout children
621 for child_id in layout_children.iter().copied() {
622 resolve_layout_children_recursive(doc, child_id);
623 doc.nodes[child_id].layout_parent.set(Some(node_id));
624 if let Some(mut data) = doc.nodes[child_id]
625 .stylo_element_data_opt_mut()
626 .and_then(|s| s.get_mut())
627 {
628 data.damage
629 .remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX);
630 }
631 }
632
633 *doc.nodes[node_id].layout_children.borrow_mut() = Some(layout_children.clone());
634 // *doc.nodes[node_id].paint_children.borrow_mut() = Some(layout_children);
635
636 damage.remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX);
637 // damage.insert(RestyleDamage::RELAYOUT | RestyleDamage::REPAINT);
638 } else {
639 //if damage.contains(CONSTRUCT_DESCENDENT) {
640 let layout_children = doc.nodes[node_id].layout_children.borrow_mut().take();
641 if let Some(layout_children) = layout_children {
642 for child_id in layout_children.iter().copied() {
643 // Anonymous blocks and pseudo-elements can be removed from the
644 // slab between render passes; skip stale IDs.
645 if !doc.nodes.contains_key(child_id) {
646 continue;
647 }
648 resolve_layout_children_recursive(doc, child_id);
649 doc.nodes[child_id].layout_parent.set(Some(node_id));
650 }
651
652 *doc.nodes[node_id].layout_children.borrow_mut() = Some(layout_children);
653 }
654
655 // damage.remove(CONSTRUCT_DESCENDENT);
656 // damage.insert(RestyleDamage::RELAYOUT | RestyleDamage::REPAINT);
657 }
658
659 doc.nodes[node_id].set_damage(damage);
660 }
661 }
662
663 /// Reparent `position: fixed` nodes onto the root element for layout.
664 ///
665 /// Taffy has no `Fixed` position, so `stylo_taffy` maps it to `Absolute`. An
666 /// absolutely positioned node resolves its insets against its containing
667 /// block, which for a fixed node must be the viewport. Laid out in place it
668 /// would instead resolve against the nearest positioned ancestor, so both its
669 /// offset and — when opposite insets are set — its size come out wrong.
670 ///
671 /// Reparenting them onto the root element takes the positioned ancestor out
672 /// of the picture. This runs after `resolve_layout_children` and before
673 /// `flush_styles_to_layout`, which derives `paint_children` from
674 /// `layout_children`, so painting and hit testing follow the hoist without
675 /// further work.
676 ///
677 /// Note this is not yet the full containing block a browser would use. The
678 /// root element takes its height from its content, whereas the initial
679 /// containing block is always viewport-sized, so `inset: 0` still sizes
680 /// against the document rather than the viewport. Closing that gap needs an
681 /// ICB distinct from the root element.
682 ///
683 /// A transformed ancestor becomes the containing block for its fixed
684 /// descendants, so those are left where they are.
685 ///
686 /// <https://drafts.csswg.org/css-position/#fixed-pos>
687 /// <https://drafts.csswg.org/css-transforms-1/#propdef-transform>
688 pub fn hoist_fixed_position_nodes(&mut self) {
689 let root_id = self.root_element().id;
690
691 let mut hoisted: Vec<NodeId> = Vec::new();
692 collect_fixed(self, root_id, false, &mut hoisted);
693
694 // Drop nodes that are no longer fixed, and keep the rest.
695 //
696 // This used to `clear()` and rebuild, which worked exactly once. The
697 // loop below reads `layout_parent` to learn where a node came from, but
698 // the hoist itself sets that to the root, so on the second pass every
699 // already-hoisted node takes the `parent_id == root_id` branch and is
700 // never re-recorded. Combined with the clear, the map came back empty
701 // and `flush_styles_to_layout` put the layer in the root's stacking
702 // context instead of the one its box tree gives it.
703 //
704 // The symptom was a full-bleed background that painted correctly on the
705 // first frame and disappeared on the next relayout, which on a real
706 // page means as soon as an image finishes loading.
707 let still_fixed: HashSet<NodeId> = hoisted.iter().copied().collect();
708 self.hoisted_fixed_parents
709 .retain(|node_id, _| still_fixed.contains(node_id));
710
711 for node_id in hoisted {
712 let Some(parent_id) = self.nodes[node_id].layout_parent.get() else {
713 continue;
714 };
715 if parent_id == root_id {
716 continue;
717 }
718
719 // Remember where it came from. The hoist decides the containing
720 // block; the box tree still decides the stacking context, and
721 // `flush_styles_to_layout` reads this to keep them apart.
722 self.hoisted_fixed_parents.insert(node_id, parent_id);
723
724 if let Some(children) = self.nodes[parent_id].layout_children.borrow_mut().as_mut() {
725 children.retain(|id| *id != node_id);
726 }
727 if let Some(children) = self.nodes[root_id].layout_children.borrow_mut().as_mut() {
728 children.push(node_id);
729 }
730 self.nodes[node_id].layout_parent.set(Some(root_id));
731 }
732
733 fn collect_fixed(
734 doc: &BaseDocument,
735 node_id: NodeId,
736 under_transform: bool,
737 out: &mut Vec<NodeId>,
738 ) {
739 let children = doc.nodes[node_id].layout_children.borrow().clone();
740 let Some(children) = children else {
741 return;
742 };
743
744 for child_id in children {
745 let Some(child) = doc.nodes.get(child_id) else {
746 continue;
747 };
748 let Some(styles) = child.primary_styles() else {
749 continue;
750 };
751
752 // A hidden subtree generates no boxes, so nothing in it may be
753 // hoisted. This walk had no display check because it could not
754 // reach a hidden subtree: hiding a pane emptied its layout
755 // children and stylo discarded its styles, so the recursion
756 // stopped and `primary_styles` returned None. Now that a hidden
757 // pane keeps both, every `position: fixed` element in every
758 // background tab was hoisted onto the root and painted over the
759 // tab in front, one ghost per retained tab.
760 if styles.clone_display().is_none() {
761 continue;
762 }
763
764 if !under_transform && styles.clone_position() == Position::Fixed {
765 out.push(child_id);
766 }
767
768 collect_fixed(
769 doc,
770 child_id,
771 under_transform || establishes_containing_block(&styles),
772 out,
773 );
774 }
775 }
776
777 /// Whether a node becomes the containing block for fixed descendants.
778 ///
779 /// TODO: `filter`, `backdrop-filter`, `will-change`, `contain` and
780 /// `perspective` also do this.
781 fn establishes_containing_block(styles: &ComputedValues) -> bool {
782 let box_styles = styles.get_box();
783 !box_styles.transform.0.is_empty()
784 || !matches!(box_styles.translate, Translate::None)
785 || !matches!(box_styles.rotate, Rotate::None)
786 || !matches!(box_styles.scale, Scale::None)
787 }
788 }
789
790 /// Give each held fixed layer the offset that cancels its hoist.
791 ///
792 /// Paint draws a hoisted child at its stacking context root's origin, plus
793 /// the recorded offset, plus the node's own layout location — and that
794 /// location is relative to the root element, because the hoist made the
795 /// root its layout parent. So the offset has to carry the difference
796 /// between the two origins, or a background mounted with `inset: 0` lands
797 /// wherever its isolate happens to sit rather than over the viewport.
798 ///
799 /// Separate from `flush_styles_to_layout`, which decides *which* context
800 /// holds the layer: that runs before taffy, when every absolute position is
801 /// still zero.
802 pub(crate) fn correct_hoisted_fixed_positions(&mut self) {
803 if self.hoisted_fixed_parents.is_empty() {
804 return;
805 }
806 let root_id = self.root_element().id;
807 let root_abs = self.nodes[root_id].absolute_position(0.0, 0.0);
808
809 let placements: Vec<(NodeId, NodeId)> = self
810 .hoisted_fixed_parents
811 .iter()
812 .filter_map(|(&node_id, &origin)| {
813 let host = self.nearest_stacking_context_ancestor(origin)?;
814 (host != root_id).then_some((node_id, host))
815 })
816 .collect();
817
818 for (node_id, host) in placements {
819 let host_abs = self.nodes[host].absolute_position(0.0, 0.0);
820 let Some(context) = self.nodes[host].stacking_context.as_mut() else {
821 continue;
822 };
823 for child in context.children.iter_mut() {
824 if child.node_id == node_id {
825 child.position = taffy::Point {
826 x: root_abs.x - host_abs.x,
827 y: root_abs.y - host_abs.y,
828 };
829 }
830 }
831 }
832 }
833
834 /// Recompute hoisted paint offsets from the layout that just finished.
835 ///
836 /// Stacking contexts are assembled while styles are flushed into Taffy,
837 /// before Taffy computes this frame's boxes. Accumulating ancestor
838 /// locations during that flush therefore reads zeroes on the first frame
839 /// and previous-frame positions after relayout. The next unrelated
840 /// restyle rebuilds the same context from current boxes, which made a
841 /// transformed flower repair itself on its first hover.
842 ///
843 /// A hoisted child is painted from the context host, then by this offset,
844 /// then by its own layout location. Its layout parent's document position
845 /// relative to the host is therefore the exact offset needed here. Fixed
846 /// children are the exception: their box tree was deliberately reparented
847 /// to the root and [`Self::correct_hoisted_fixed_positions`] restores their
848 /// authored stacking-context offset separately.
849 pub(crate) fn resolve_hoisted_positions(&mut self) {
850 let hosts: Vec<NodeId> = self
851 .nodes
852 .iter()
853 .filter_map(|(node_id, node)| node.stacking_context.is_some().then_some(node_id))
854 .collect();
855
856 for host in hosts {
857 let host_position = self.nodes[host].absolute_position(0.0, 0.0);
858 let Some(mut context) = self.nodes[host].stacking_context.take() else {
859 continue;
860 };
861
862 for child in context.children.iter_mut() {
863 let node = &self.nodes[child.node_id];
864 if node
865 .primary_styles()
866 .is_some_and(|styles| styles.clone_position() == Position::Fixed)
867 {
868 continue;
869 }
870 let child_position = node.absolute_position(0.0, 0.0);
871 let child_layout_position = node.final_layout().location;
872 child.position = taffy::Point {
873 x: child_position.x - host_position.x - child_layout_position.x,
874 y: child_position.y - host_position.y - child_layout_position.y,
875 };
876 }
877
878 context.compute_content_size(self);
879 self.nodes[host].stacking_context = Some(context);
880 }
881 }
882
883 /// Turn each hoisted child's clipping ancestors into rectangles paint can
884 /// use, relative to the origin of the stacking context it paints in.
885 ///
886 /// Separate from `flush_styles_to_layout`, which decides *which* ancestors
887 /// clip: that runs before taffy, when every box is still zero-sized, so
888 /// reading a size there produced an empty clip and made hoisted content
889 /// disappear entirely rather than merely escape.
890 pub(crate) fn resolve_hoisted_clips(&mut self) {
891 if self.hoisted_clip_hosts.is_empty() {
892 return;
893 }
894
895 // By index, leaving the list in place: it belongs to the last flush,
896 // and layout can run more than once against it.
897 for index in 0..self.hoisted_clip_hosts.len() {
898 let host = self.hoisted_clip_hosts[index];
899 let Some(mut context) = self.nodes[host].stacking_context.take() else {
900 continue;
901 };
902 let host_position = self.nodes[host].absolute_position(0.0, 0.0);
903
904 for child in context.children.iter_mut() {
905 child.clips.clear();
906 child.clips.reserve(child.clip_ancestors.len());
907 for &clipper in child.clip_ancestors.iter() {
908 let node = &self.nodes[clipper];
909 // The clip is the clipping box's own border box, so its
910 // own scroll offset does not enter into it. Ancestor
911 // scrolling does, and `absolute_position` applies that.
912 let position = node.absolute_position(0.0, 0.0);
913 let layout = node.final_layout();
914 let left = position.x - host_position.x;
915 let top = position.y - host_position.y;
916 // The padding box, matching what paint clips content to.
917 child.clips.push(taffy::Rect {
918 left: left + layout.border.left,
919 top: top + layout.border.top,
920 right: left + layout.size.width - layout.border.right,
921 bottom: top + layout.size.height - layout.border.bottom,
922 });
923 }
924 }
925
926 self.nodes[host].stacking_context = Some(context);
927 }
928 }
929
930 pub fn resolve_deferred_tasks(&mut self) {
931 let mut deferred_construction_nodes = std::mem::take(&mut self.deferred_construction_nodes);
932
933 // Deduplicate deferred tasks by node_id to avoid redundant work
934 deferred_construction_nodes.sort_unstable_by_key(|task| task.node_id);
935 deferred_construction_nodes.dedup_by_key(|task| task.node_id);
936
937 #[cfg(feature = "parallel-construct")]
938 let iter = deferred_construction_nodes.into_par_iter();
939 #[cfg(not(feature = "parallel-construct"))]
940 let iter = deferred_construction_nodes.into_iter();
941
942 let results: Vec<ConstructionTaskResult> = iter
943 .map(|task: ConstructionTask| match task.data {
944 ConstructionTaskData::InlineLayout(mut layout) => {
945 #[cfg(feature = "parallel-construct")]
946 let mut layout_ctx = LAYOUT_CTX
947 .take()
948 .unwrap_or_else(|| Box::new(LayoutContext::new()));
949 #[cfg(feature = "parallel-construct")]
950 let layout_ctx_mut = &mut layout_ctx;
951
952 #[cfg(feature = "parallel-construct")]
953 let mut font_ctx = self
954 .thread_font_contexts
955 .get_or(|| RefCell::new(Box::new(self.font_ctx.lock().unwrap().clone())))
956 .borrow_mut();
957 #[cfg(feature = "parallel-construct")]
958 let font_ctx_mut = &mut *font_ctx;
959
960 #[cfg(not(feature = "parallel-construct"))]
961 let layout_ctx_mut = &mut self.layout_ctx;
962 #[cfg(not(feature = "parallel-construct"))]
963 let font_ctx_mut = &mut *self.font_ctx.lock().unwrap();
964
965 layout.content_widths = None;
966 build_inline_layout_into(
967 &self.nodes,
968 layout_ctx_mut,
969 font_ctx_mut,
970 &mut layout,
971 self.viewport.scale(),
972 task.node_id,
973 );
974
975 #[cfg(feature = "parallel-construct")]
976 {
977 LAYOUT_CTX.set(Some(layout_ctx));
978 }
979
980 // If layout doesn't contain any inline boxes, then it is safe to populate the content_widths
981 // cache during this parallelized stage.
982 // if layout.layout.inline_boxes().is_empty() {
983 // layout.content_widths();
984 // }
985
986 ConstructionTaskResult {
987 node_id: task.node_id,
988 data: ConstructionTaskResultData::InlineLayout(layout),
989 }
990 }
991 })
992 .collect();
993
994 for result in results {
995 match result.data {
996 ConstructionTaskResultData::InlineLayout(layout) => {
997 // The node and every layout ancestor. The shaped layout
998 // that lands here has not been broken into lines yet, and
999 // an ancestor still holding a cached layout never descends,
1000 // so clearing this node alone leaves the fresh unbroken
1001 // layout in place with nothing to break it. Non-atomic
1002 // inline elements then report geometry from a single line
1003 // as wide as the whole paragraph.
1004 //
1005 // `layout_parent`, not `parent`: taffy walks the layout
1006 // tree, and anonymous blocks make the two chains differ.
1007 self.nodes[result.node_id].cache_mut().clear();
1008 self.nodes[result.node_id]
1009 .element_data_mut()
1010 .unwrap()
1011 .inline_layout_data = Some(layout);
1012 }
1013 }
1014 }
1015
1016 self.deferred_construction_nodes.clear();
1017 }
1018
1019 /// Walk the nodes now that they're properly styled and transfer their styles to the taffy style system
1020 ///
1021 /// TODO: update taffy to use an associated type instead of slab key
1022 /// TODO: update taffy to support traited styles so we don't even need to rely on taffy for storage
1023 pub fn resolve_layout(&mut self) {
1024 let size = self.stylist.device().au_viewport_size();
1025
1026 let available_space = taffy::Size {
1027 width: AvailableSpace::Definite(size.width.to_f32_px()),
1028 height: AvailableSpace::Definite(size.height.to_f32_px()),
1029 };
1030
1031 let root_element_id = crate::taffy_node_id(self.root_element().id);
1032
1033 // println!("\n\nRESOLVE LAYOUT\n===========\n");
1034
1035 taffy::compute_root_layout(self, root_element_id, available_space);
1036 taffy::round_layout(self, root_element_id);
1037
1038 // Taffy currently maps CSS `position: fixed` to absolute positioning,
1039 // which leaves the box relative to its DOM layout parent. A portal
1040 // mounted after a full-height application root therefore starts one
1041 // viewport below the window even with `top: 0`. Cancel the layout
1042 // parent's document-space offset so fixed boxes use the viewport as
1043 // their containing block, as CSS requires.
1044 let fixed_nodes = self
1045 .nodes
1046 .iter()
1047 .filter_map(|(node_id, node)| {
1048 let is_fixed = node
1049 .primary_styles()
1050 .is_some_and(|style| style.clone_position() == Position::Fixed);
1051 is_fixed.then_some((node_id, node.layout_parent.get()))
1052 })
1053 .collect::<Vec<_>>();
1054
1055 for (node_id, parent_id) in fixed_nodes {
1056 let Some(parent_id) = parent_id else {
1057 continue;
1058 };
1059 let parent_position = self.nodes[parent_id].absolute_position(0.0, 0.0);
1060 self.nodes[node_id].final_layout_mut().location.x -= parent_position.x;
1061 self.nodes[node_id].final_layout_mut().location.y -= parent_position.y;
1062 }
1063
1064 // println!("\n\n");
1065 // taffy::print_tree(self, root_node_id)
1066 }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use crate::{BaseDocument, DocumentConfig};
1072
1073 #[test]
1074 fn resolving_for_inspection_cannot_rewind_the_animation_clock() {
1075 let mut document = BaseDocument::new(DocumentConfig::default());
1076 document.resolve(2.5);
1077 document.resolve(0.0);
1078 assert_eq!(document.last_resolve_animation_time, 2.5);
1079 }
1080}