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