Skip to main content

blitz_dom/
resolve.rs

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