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