Skip to main content

blitz_dom/
resolve.rs

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