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