A Touch of Class

fantasai
2023-06-22

Implementing align-content for Blocks
aka Why Is Layout Hard?
aka An Introduction to the Gecko Layout Engine

At CSS Day, Rego walked us all through a simple fix in Blink, explaining the process of creating a patch to a major layout engine. I thought it was a great talk, but it didn't get too deep into the guts of what makes a browser’s layout engine complicated. Browser code is complicated because it is heavily optimized for dynamic changes, and because it needs to work for all kinds of content, always. But what does that really mean? What's it like inside a layout engine? And why are seemingly dead-simple features complicated to implement?

I figured I'd explain by walking you through a dead-simple feature: implementing the align-content property for display: block. A feature that is very simple to understand, but requires getting deep into the guts of the layout engine.

All it does is take the contents of a block element (such as a DIV) and align it vertically within the box: to the start, center, or end. The style system part (that parses and calculates the computed value) is already implemented, thanks to Flexbox and Grid: we just need to implement layout for block boxes.

This page documents the principal patch for Gecko bug 1684236, “Implement align-content for block containers”, and in the process, I hope, gives you an understanding of how Gecko’s layout engine works, and why something so simple on the surface is in fact not quite so simple when you translate it into platform-level code. :)

Note: If you’re already familiar with Gecko’s codebase, you might want to read this version.

Building the right frame tree

In CSS, each (visible) element in the document is represented by a “box” in the box tree. (See CSS Display: Introduction.) In Gecko, these are represented by objects called “frames”. (Technically, each Gecko “frame” is a box fragment, but as long as we’re not paginating, it’s equivalent to a CSS box.) These are arranged into a “frame tree”, mirroring the document’s element tree.

The first, and simplest, change that align-content applies per spec is forcing an independent formatting context: turning the block, if it is not already, into a block formatting context root (just like display: flow-root) so that it fully contains its descendant margins and floats.

The frame tree is built in nsCSSFrameConstructor, and the bit we need to modify is in nsCSSFrameConstructor::ConstructNonScrollableBlock():

diff --git a/layout/base/nsCSSFrameConstructor.cpp b/layout/base/nsCSSFrameConstructor.cpp
--- a/layout/base/nsCSSFrameConstructor.cpp
+++ b/layout/base/nsCSSFrameConstructor.cpp
@@ -4582,19 +4582,21 @@ nsIFrame* nsCSSFrameConstructor::Constru
   ComputedStyle* const computedStyle = aItem.mComputedStyle;

   // We want a block formatting context root in paginated contexts for
   // every block that would be scrollable in a non-paginated context.
   // We mark our blocks with a bit here if this condition is true, so
   // we can check it later in nsIFrame::ApplyPaginatedOverflowClipping.
   bool clipPaginatedOverflow =
       (aItem.mFCData->mBits & FCDATA_FORCED_NON_SCROLLABLE_BLOCK) != 0;
+  bool isAligned = computedStyle->StylePosition()->mAlignContent.primary !=
+                   mozilla::StyleAlignFlags::NORMAL;
   nsFrameState flags = nsFrameState(0);
   if ((aDisplay->IsAbsolutelyPositionedStyle() || aDisplay->IsFloatingStyle() ||
-       aDisplay->DisplayInside() == StyleDisplayInside::FlowRoot ||
+       aDisplay->DisplayInside() == StyleDisplayInside::FlowRoot || isAligned ||
        clipPaginatedOverflow) &&
       !aParentFrame->IsInSVGTextSubtree()) {
     flags = NS_BLOCK_FORMATTING_CONTEXT_STATE_BITS;
     if (clipPaginatedOverflow) {
       flags |= NS_BLOCK_CLIP_PAGINATED_OVERFLOW;
     }
   }

Pretty simple, right? But what if the document changes?

Making Style Changes Trigger Frame Reconstruction

Because browser layout engines are optimized for changes, they want to do the minimum work possible to update the layout when something changes.

In Gecko, we track the type of changes needed using nsChangeHint, which lets the style engine tell the layout engine what type of changes are needed for a given style change. Examples are:

nsChangeHint_RepaintFrame
Repaint only (e.g. color or background changed)
nsChangeHint_NeedReflow
Recalculate layout (sizes and positions) of this box, and anything that depends on it.
nsChangeHint_ReconstructFrame
Destroy and reconstruct this segment of the frame tree (e.g. display property changed)

Layout would be really easy if we reconstructed everything every time, but it's costly, so we want to avoid it. For the most part, align-content value changes only require relayout. But if the value changes between normal and a non-normal value on a block box, that can change whether the block box is a block formatting context root—and that is not yet something Gecko can handle without frame reconstruction.

So the next step in our patch is to tell nsStyleStruct::CalcDifference() to trigger frame reconstruction whenever a block frame switches between normal and non-normal align-content values—but ideally only when it's a block box, and only when nothing else is already forcing that block to be a block formatting context root. To make these checks, it needs to also pass in nsStyleDisplay, so we also have to update the parameters to pass that in:

 nsChangeHint nsStylePosition::CalcDifference(
     const nsStylePosition& aNewData,
-    const nsStyleVisibility& aOldStyleVisibility) const {
+    const nsStyleVisibility& aOldStyleVisibility,
+    const nsStyleDisplay& aOldStyleDisplay) const {
   if (mGridTemplateColumns.IsMasonry() !=
           aNewData.mGridTemplateColumns.IsMasonry() ||
       mGridTemplateRows.IsMasonry() != aNewData.mGridTemplateRows.IsMasonry()) {
     // XXXmats this could be optimized to AllReflowHints with a bit of work,
     // but I'll assume this is a very rare use case in practice. (bug 1623886)
     return nsChangeHint_ReconstructFrame;
   }

+  if (  // alignment switched between normal / non-normal
+      ((mAlignContent.primary == mozilla::StyleAlignFlags::NORMAL) !=
+       (aNewData.mAlignContent.primary == mozilla::StyleAlignFlags::NORMAL)) &&
+      // is a block box
+      aOldStyleDisplay.IsBlockOutsideStyle() &&
+      aOldStyleDisplay.DisplayInside() == mozilla::StyleDisplayInside::Flow &&
+      // not a scroll container (which is always a flow-root anyway)
+      (aOldStyleDisplay.mOverflowX == mozilla::StyleOverflow::Visible ||
+       aOldStyleDisplay.mOverflowX == mozilla::StyleOverflow::Clip) &&
+      (aOldStyleDisplay.mOverflowY == mozilla::StyleOverflow::Visible ||
+       aOldStyleDisplay.mOverflowY == mozilla::StyleOverflow::Clip) &&
+      // not out of flow (which is always a flow-root anyway)
+      aOldStyleDisplay.mFloat == mozilla::StyleFloat::None &&
+      aOldStyleDisplay.mPosition != mozilla::StylePositionProperty::Absolute &&
+      aOldStyleDisplay.mPosition != mozilla::StylePositionProperty::Fixed) {
+    // Change in align-content causes switch between block flow / flow-root
+    return nsChangeHint_ReconstructFrame;
+    // XXX It would be best if we could not do this if we're a flex / grid item,
+    // but we can't tell because that depends on the parent display. :(
+  }
+
   nsChangeHint hint = nsChangeHint(0);

   // Changes to "z-index" require a repaint.
   if (mZIndex != aNewData.mZIndex) {
     hint |= nsChangeHint_RepaintFrame;
   }

   // Changes to "object-fit" & "object-position" require a repaint.  They

And of course, nsStyleStruct.h is also altered to pass nsStyleDisplay:

nsStyleStruct.h/CalcDifference()
diff --git a/layout/style/nsStyleStruct.h b/layout/style/nsStyleStruct.h
--- a/layout/style/nsStyleStruct.h
+++ b/layout/style/nsStyleStruct.h
@@ -713,19 +713,19 @@ struct MOZ_NEEDS_MEMMOVABLE_MEMBERS nsSt
   using StyleAlignSelf = mozilla::StyleAlignSelf;
   using StyleJustifySelf = mozilla::StyleJustifySelf;

   explicit nsStylePosition(const mozilla::dom::Document&);
   nsStylePosition(const nsStylePosition& aOther);
   ~nsStylePosition();
   static constexpr bool kHasTriggerImageLoads = false;

-  nsChangeHint CalcDifference(
-      const nsStylePosition& aNewData,
-      const nsStyleVisibility& aOldStyleVisibility) const;
+  nsChangeHint CalcDifference(const nsStylePosition& aNewData,
+                              const nsStyleVisibility& aOldStyleVisibility,
+                              const nsStyleDisplay& aStyleDisplay) const;

   // Returns whether we need to compute an hypothetical position if we were
   // absolutely positioned.
   bool NeedsHypotheticalPositionIfAbsPos() const {
     return (mOffset.Get(mozilla::eSideRight).IsAuto() &&
             mOffset.Get(mozilla::eSideLeft).IsAuto()) ||
            (mOffset.Get(mozilla::eSideTop).IsAuto() &&
             mOffset.Get(mozilla::eSideBottom).IsAuto());

And so is the template in ComputedStyle.cpp:

ComputedStyle.cpp
diff --git a/layout/style/ComputedStyle.cpp b/layout/style/ComputedStyle.cpp
--- a/layout/style/ComputedStyle.cpp
+++ b/layout/style/ComputedStyle.cpp
@@ -162,17 +162,18 @@ nsChangeHint ComputedStyle::CalcStyleDif
   DO_STRUCT_DIFFERENCE(Outline);
   DO_STRUCT_DIFFERENCE(TableBorder);
   DO_STRUCT_DIFFERENCE(Table);
   DO_STRUCT_DIFFERENCE(UIReset);
   DO_STRUCT_DIFFERENCE(Text);
   DO_STRUCT_DIFFERENCE_WITH_ARGS(List, (, *StyleDisplay()));
   DO_STRUCT_DIFFERENCE(SVGReset);
   DO_STRUCT_DIFFERENCE(SVG);
-  DO_STRUCT_DIFFERENCE_WITH_ARGS(Position, (, *StyleVisibility()));
+  DO_STRUCT_DIFFERENCE_WITH_ARGS(Position,
+                                 (, *StyleVisibility(), *StyleDisplay()));
   DO_STRUCT_DIFFERENCE(Font);
   DO_STRUCT_DIFFERENCE(Margin);
   DO_STRUCT_DIFFERENCE(Padding);
   DO_STRUCT_DIFFERENCE(Border);
   DO_STRUCT_DIFFERENCE(TextReset);
   DO_STRUCT_DIFFERENCE(Effects);
   DO_STRUCT_DIFFERENCE(Background);
   DO_STRUCT_DIFFERENCE(Page);

Aligning the Contents of nsBlockFrame

The core method of Gecko’s layout engine is nsIFrame::Reflow(), which implements layout—i.e. sizing and positioning the frames. Every frame type inherits and specializes this function, calling the method on its children for them to size themselves and their content, then positioning those children, and finally calculating its own size to pass up to its parent.

Implementing align-content requires modifying the calculations in the Reflow() method for block container boxes (which are represented by nsBlockFrame). In particular, we need to perform alignment after we’ve calculated both the block’s own size as well as the sizes and positions of its content, because the difference in these sizes is the extra space we have to play with for alignment.

The next segment of the patch adds a call to AlignContent() in nsBlockFrame::Reflow(), right after ComputeFinalSize(), and before we reflow any absolutely-positioned boxes for which this block is a containing block (so that their static positions will be correct).

Note: The inputs to the Reflow() method (such as available space) are represented in a ReflowInput object; the output (the frame’s desired size) in a ReflowOutput object typically called aMetrics. Since the ::Reflow() method of nsBlockFrame is quite complicated, it calls many subroutines, maintaining state using a helper object called BlockReflowState which it passes around. This object contains a copy of the ReflowInput and caches a variety of other hot data, in addition to maintaining the state of the reflow.

diff --git a/layout/generic/nsBlockFrame.cpp b/layout/generic/nsBlockFrame.cpp
--- a/layout/generic/nsBlockFrame.cpp
+++ b/layout/generic/nsBlockFrame.cpp
@@ -1608,16 +1608,19 @@ void nsBlockFrame::Reflow(nsPresContext*
   }

   CheckFloats(state);

   // Compute our final size
   nscoord blockEndEdgeOfChildren;
   ComputeFinalSize(aReflowInput, state, aMetrics, &blockEndEdgeOfChildren);

+  // Align content
+  AlignContent(state, aMetrics, &blockEndEdgeOfChildren);
+
   // If the block direction is right-to-left, we need to update the bounds of
   // lines that were placed relative to mContainerSize during reflow, as

And now we need to actually define the AlignContent() method. The premise of this function is very simple:

  • If the block is start-aligned, it short-circuits and returns.
  • If the block is end- or center-aligned, it calculates a shift value from the distance between the end of the block’s contents and the bottom edge of the block, and then loops through the block’s contents to apply the shift.

You'll notice that Gecko’s reflow methods calculate primarily in I and B coordinates instead of X and Y coordinates. This is because layout is performed in flow-relative coordinates (i.e. writing-mode–relative coordinates). However, the frames store their sizes and position in X and Y coordinates (for speed of painting, hit testing, etc.). The WritingMode object is used to translate between these coordinate systems.

There's some fancy caching happening through aState.mAlignContentShift. Ignore this value (assume it’s zero) on your first read-through; we’ll get back to it later. Also ignore anything about “fragmentation” for now.

+void nsBlockFrame::AlignContent(BlockReflowState& aState,
+                                ReflowOutput& aMetrics,
+                                nscoord* aBEndEdgeOfChildren) {
+  if (!HasAllStateBits(NS_BLOCK_FORMATTING_CONTEXT_STATE_BITS))
+    return;  // non-BFC can't have been aligned
+
+  StyleAlignFlags alignment = StylePosition()->mAlignContent.primary;
+  alignment &= ~StyleAlignFlags::FLAG_BITS;
+
+  // Short circuit
+  bool isCentered = alignment == mozilla::StyleAlignFlags::CENTER ||
+                    alignment == mozilla::StyleAlignFlags::SPACE_AROUND ||
+                    alignment == mozilla::StyleAlignFlags::SPACE_EVENLY;
+  bool isEndAlign = alignment == mozilla::StyleAlignFlags::END ||
+                    alignment == mozilla::StyleAlignFlags::FLEX_END ||
+                    alignment == mozilla::StyleAlignFlags::LAST_BASELINE;
+  if (!(isEndAlign) && !isCentered    // desired shift = 0
+      && !aState.mAlignContentShift)  // no cached shift to undo
+    return;
+
+  // NOTE: ComputeFinalSize already called aState.UndoAlignContentShift(),
+  //       so metrics no longer include cached shift.
+  // NOTE: Content is currently positioned at cached shift
+  // NOTE: Content has been fragmented against 0-shift assumption.
+
+  // Calculate shift
+  nscoord shift = 0;
+  WritingMode wm = aState.mReflowInput.GetWritingMode();
+  if ((isCentered || isEndAlign) && !mLines.empty() &&
+      aState.mReflowStatus.IsFullyComplete() && !GetPrevInFlow()) {
+    nscoord availB = aState.mReflowInput.AvailableBSize();
+    nscoord endB = aMetrics.BSize(wm) - aState.BorderPadding().BEnd(wm);
+    shift = std::min(availB, endB) - (*aBEndEdgeOfChildren);
+
+    // note: these measures all include start BP, so it subtracts out
+    if (!(StylePosition()->mAlignContent.primary &
+          mozilla::StyleAlignFlags::UNSAFE)) {
+      shift = std::max(0, shift);
+    }
+    if (isCentered) {
+      shift = shift / 2;
+    }
+
+    // Handle limitations due to fragmentation
+    if (availB != NS_UNCONSTRAINEDSIZE) {
+      nscoord maxShift = availB - aState.mOverflowBCoord;
+      if (shift > maxShift) {
+        // avoid needing overflow fragmentation
+        shift = maxShift;
+        // trigger reflow if availB increases
+        aState.mFlags.mHasAlignContentOverflow = true;
+      }
+    }
+    // XXX Should also floor negative shift by distance to top of page
+    //     so it doesn't get clipped when printing.
+  }
+  // else: zero shift if start-aligned or if fragmented
+
+  nscoord delta = shift - aState.mAlignContentShift;
+  if (delta) {
+    // Shift children
+    LogicalPoint translation(wm, 0, delta);
+    for (LineIterator line = LinesBegin(), line_end = LinesEnd();
+         line != line_end; ++line) {
+      SlideLine(aState, line, delta);
+    }
+    for (nsIFrame* kid = GetChildList(FrameChildListID::Float).FirstChild();
+         kid; kid = kid->GetNextSibling()) {
+      kid->MovePositionBy(wm, translation);
+      nsContainerFrame::PlaceFrameView(kid);
+    }
+    if (HasOutsideMarker() && !mLines.empty()) {
+      nsIFrame* marker = GetOutsideMarker();
+      marker->MovePositionBy(wm, translation);
+    }
+
+    // Cache shift
+    SetProperty(AlignContentShift(), shift);
+  }
+
+  if (!shift) {
+    RemoveProperty(AlignContentShift());
+  }
+}
+

A few comments on what’s going on:

  • aState.mReflowStatus.IsFullyComplete() && !GetPrevInFlow() is checking whether the box is fragmented (split across pages or columns). If that happens, we skip performing alignment on any of the box fragments. (Fixing that properly requires some additional tweaking that we didn't tackle in this patch.)
  • aState.mReflowInput.AvailableBSize() (assigned into availB) represents the amount of space from the top of the nsBlockFrame to the bottom of the page/column. Anything past this gets clipped, so we don't want to push content past that edge.
  • The “lines” in nsBlockFrame contain its in-flow children: they're a lightweight wrapper around one or more child frame objects. Block children each exist on a line by themselves, and inline boxes are laid out into one or more lines (see nsLineLayout).

With that, you should be able to go back and understand exactly what we're doing in this patch (if you read it slowly enough). :)

And of course, we also need to declare the new function in nsBlockFrame.h. (Ignore the property declarations for now, we’ll get back to them later.)

diff --git a/layout/generic/nsBlockFrame.h b/layout/generic/nsBlockFrame.h
--- a/layout/generic/nsBlockFrame.h
+++ b/layout/generic/nsBlockFrame.h
@@ -481,16 +481,26 @@ class nsBlockFrame : public nsContainerF
   void ComputeFinalSize(const ReflowInput& aReflowInput,
                         BlockReflowState& aState, ReflowOutput& aMetrics,
                         nscoord* aBEndEdgeOfChildren);

   /**
+   * Calculates the necessary shift to honor 'align-content' and applies it.
+   */
+  void AlignContent(BlockReflowState& aState, ReflowOutput& aMetrics,
+                    nscoord* aBEndEdgeOfChildren);
+  // Stash the effective align-content shift value between reflows
+  NS_DECLARE_FRAME_PROPERTY_SMALL_VALUE(AlignContentShift, nscoord)
+  // Trigger reflow when alignment was limited due to limited AvailableBSize
+  NS_DECLARE_FRAME_PROPERTY_SMALL_VALUE(AlignContentOverflow, bool)
+
+  /**
    * Helper method for Reflow(). Computes the overflow areas created by our
    * children, and includes them into aOverflowAreas.
    */
   void ComputeOverflowAreas(mozilla::OverflowAreas& aOverflowAreas,
                             nscoord aBEndEdgeOfChildren,
                             const nsStyleDisplay* aDisplay) const;

And that’s pretty much it! It's not too bad if we ignore fragmentation and optimization. ;)

Caching the Alignment Shift

The reason Gecko’s core layout function is called Reflow() is because most of the time when it’s called it’s not calculating an initial layout; its re-doing the layout. And since Reflow() starts at the top of the tree, it means nsBlockFrame::Reflow() often gets called when only one of its descendants needs to be reflowed.

Ideally, we’re re-doing layout on the fewest number of frames necessary to handle changes. So when nsBlockFrame::Reflow() is called, it tries to avoid calling Reflow() on its children unless:

  • that child or one of its descendants requires reflow due to content or style changes, or
  • the available space changed such that the child might need to resize itself or its contents

The way Gecko tracks this is through a concept of a frame being “dirty” (which means it needs to be reflowed), or having “dirty” children (which means we need to call Reflow() so that it reviews its children and calls reflow on those that need it). A typical reason why a block might be “dirty” is if its width changed: in this case, all its children need to be reflowed to the new width. If it only has a dirty child, though, then we may only need to reflow that child. If the child got taller or shorter, we can merely reposition subsequent children without reflowing them. (Unless there are floats involved, in which case repositioning children can impact their available size, which then requires actually reflowing them...)

So. When we return to nsBlockFrame::Reflow() after having completed an initial reflow, align-content has already taken effect, which means the children might be shifted from their original position. If we don’t teach nsBlockFrame about that, it might try to re-position them to their original positions.

Since we don't want to re-position all the frames twice (which could affect thousands of children) every time we run nsBlockFrame::Reflow(), we start reflow by assuming that the alignment shift this time is going to be the same alignment shift as last time. This way, if nothing else changed in the block axis, then none of the children move at all. So we need to start layout at the shifted start position, rather than at the content edge of the block.

To manage this, we introduce a new frame property, AlignContentShift, to cache the shift amount between Reflow() calls. Frame properties store information about a frame that isn’t commonly needed, so that they don’t take up space on frame instances that don’t need them. Gecko tries to minimize the amount of data stored on the frame, because that data takes up memory even when we’re not (re-)doing layout, so while commonly-needed data (like the frame’s size and coordinates) are stored in the class structure for speed and memory optimization, more esoteric, occasionally needed variables are stored elsewhere by association tables. AlignContentShift is declared in the NS_DECLARE_FRAME_PROPERTY_SMALL_VALUE(AlignContentShift, nscoord) line right after the AlignContent() declaration in nsBlockFrame.h, see above.

We also introduce some infrastructure in BlockReflowState to help manage the shift calculations:

  • BlockReflowState retrieves and applies the shift to all of our reflow state coordinates in its constructor; and also caches the value for us
  • it also provides a rewind function (UndoAlignContentShift()), which is called by ComputeFinalSize() so that it can actually compute an accurate final size.

So first lets declare these methods:

BlockReflowState.h/UndoAlignContentShift()
  public:
   BlockReflowState(const ReflowInput& aReflowInput, nsPresContext* aPresContext,
                    nsBlockFrame* aFrame, bool aBStartMarginRoot,
                    bool aBEndMarginRoot, bool aBlockNeedsFloatManager,
                    const nscoord aConsumedBSize,
                    const nscoord aEffectiveContentBoxBSize);

   /**
+   * Unshifts coords, restores availableBSize to reality.
+   * (Constructor applies any cached shift before reflow
+   *  so that frames are reflowed with cached shift)
+   */
+  void UndoAlignContentShift();
+
+  ~BlockReflowState();
+
+  /**
    * Get the available reflow space (the area not occupied by floats)
    * for the current y coordinate. The available space is relative to
    * our coordinate system, which is the content box, with (0, 0) in the
    * upper left.
    *
    * Returns whether there are floats present at the given block-direction
    * coordinate and within the inline size of the content rect.
    *

And the cached value BlockReflowState::mAlignContentShift:

BlockReflowState.h/mAlignContentShift
@@ -392,16 +414,21 @@ class BlockReflowState {
   // Cache the result of nsBlockFrame::FindTrailingClear() from mBlock's
   // prev-in-flows. See nsBlockFrame::ReflowPushedFloats().
   StyleClear mTrailingClearFromPIF;

   // The amount of computed content block-size "consumed" by our previous
   // continuations.
   const nscoord mConsumedBSize;

+  // The amount of block-axis alignment shift to assume during reflow.
+  // Cached between reflows in the AlignContentShift property.
+  // (This system optimizes reflow for not changing the shift.)
+  nscoord mAlignContentShift;
+
   // Cache the current line's BSize if nsBlockFrame::PlaceLine() fails to
   // place the line. When redoing the line, it will be used to query the
   // accurate float available space in AddFloat() and
   // nsBlockFrame::PlaceLine().
   Maybe<nscoord> mLineBSize;

  private:
   bool CanPlaceFloat(nscoord aFloatISize,

And now here’s the helper implementation, initializing BlockReflowState with AlignContentShift and defining the BlockReflowState::UndoAlignContentShift() helper function:

BlockReflowState::BlockReflowState() and BlockReflowState::UndoAlignContentShift()
diff --git a/layout/generic/BlockReflowState.cpp b/layout/generic/BlockReflowState.cpp
--- a/layout/generic/BlockReflowState.cpp
+++ b/layout/generic/BlockReflowState.cpp
@@ -43,17 +43,18 @@ BlockReflowState::BlockReflowState(const
       mBorderPadding(
           mReflowInput
               .ComputedLogicalBorderPadding(mReflowInput.GetWritingMode())
               .ApplySkipSides(aFrame->PreReflowBlockLevelLogicalSkipSides())),
       mPrevBEndMargin(),
       mMinLineHeight(aReflowInput.GetLineHeight()),
       mLineNumber(0),
       mTrailingClearFromPIF(StyleClear::None),
-      mConsumedBSize(aConsumedBSize) {
+      mConsumedBSize(aConsumedBSize),
+      mAlignContentShift(0) {
   NS_ASSERTION(mConsumedBSize != NS_UNCONSTRAINEDSIZE,
                "The consumed block-size should be constrained!");

   WritingMode wm = aReflowInput.GetWritingMode();

   // Note that mContainerSize is the physical size, needed to
   // convert logical block-coordinates in vertical-rl writing mode
   // (measured from a RHS origin) to physical coordinates within the
@@ -130,20 +131,88 @@ BlockReflowState::BlockReflowState(const
   } else {
     // When we are not in a paginated situation, then we always use a
     // unconstrained block-size.
     mContentArea.BSize(wm) = NS_UNCONSTRAINEDSIZE;
   }
   mContentArea.IStart(wm) = mBorderPadding.IStart(wm);
   mBCoord = mContentArea.BStart(wm) = mBorderPadding.BStart(wm);

+  if (mBlock->HasAllStateBits(NS_BLOCK_FORMATTING_CONTEXT_STATE_BITS)) {
+    // We can skip this lookup on non-BFC frames; they can't be aligned.
+
+    // Cache the lookup
+    mAlignContentShift = aFrame->GetProperty(nsBlockFrame::AlignContentShift());
+
+    // Account for cached shift ; re-position in AlignContent() if needed
+    if (mAlignContentShift) {
+      mBCoord += mAlignContentShift;
+      mOverflowBCoord += mAlignContentShift;
+      mContentArea.BStart(wm) += mAlignContentShift;
+
+      if (mReflowInput.AvailableBSize() != NS_UNCONSTRAINEDSIZE) {
+        const_cast<ReflowInput&>(mReflowInput)
+            .SetAvailableBSize(mReflowInput.AvailableBSize() +
+                               mAlignContentShift);
+        mContentArea.BSize(wm) += mAlignContentShift;
+      }
+    }
+  }
+
   mPrevChild = nullptr;
   mCurrentLine = aFrame->LinesEnd();
 }

+void BlockReflowState::UndoAlignContentShift() {
+  if (!mAlignContentShift) return;
+
+  mBCoord -= mAlignContentShift;
+  mOverflowBCoord -= mAlignContentShift;
+  mContentArea.BStart(mReflowInput.GetWritingMode()) -= mAlignContentShift;
+
+  if (mReflowInput.AvailableBSize() != NS_UNCONSTRAINEDSIZE) {
+    const_cast<ReflowInput&>(mReflowInput)
+        .SetAvailableBSize(mReflowInput.AvailableBSize() - mAlignContentShift);
+    mContentArea.BSize(mReflowInput.GetWritingMode()) -= mAlignContentShift;
+  }
+}

Lastly, this segment calls BlockReflowState::UndoAlignContentShift() from halfway through ComputeFinalSize(): after we compute the blockEndEdgeOfChildren (which is working with the currently applied, shifted coordinates of the children) and before we compute the final metrics (which needs to assume they’re not shifted in order to calculate the correct size for the block):

nsBlockFrame::ComputeFinalSize()
@@ -1970,16 +1973,21 @@ void nsBlockFrame::ComputeFinalSize(cons
     // block-end margin of any floated elements; e.g., inside a table cell.
     //
     // Note: The block coordinate returned by ClearFloats is always greater than
     // or equal to blockEndEdgeOfChildren.
     std::tie(blockEndEdgeOfChildren, std::ignore) =
         aState.ClearFloats(blockEndEdgeOfChildren, StyleClear::Both);
   }

+  // undo cached alignment shift for sizing purposes
+  // (we used shifted positions because the float manager uses them)
+  blockEndEdgeOfChildren -= aState.mAlignContentShift;
+  aState.UndoAlignContentShift();
+
   if (NS_UNCONSTRAINEDSIZE != aReflowInput.ComputedBSize()) {
     // Note: We don't use blockEndEdgeOfChildren because it includes the
     // previous margin.
     const nscoord contentBSizeWithBStartBP =
         aState.mBCoord + nonCarriedOutBDirMargin;

     // We don't care about ApplyLineClamp's return value (the line-clamped
     // content BSize) in this explicit-BSize codepath, but we do still need to

The rest of the handling for the cached shift value is in nsBlockFrame::AlignContent(), see above, this time paying attention to mAlignContentShift. :)

Fragmentation

Fragmentation is the process of splitting a box across pages (when printing) or columns (in a multi-column container). Pagination is one form of fragmentation.

Gecko’s Fragmentation Model

Gecko’s fragmentation model is very powerful. It was originally created as a print document layout engine, and architecturally it’s capable of a lot more than it’s doing right now. But it’s also very delicate, and dynamic changes are tricky to get right in the heavily cached environment of Reflow().

Earlier we encountered some checks on fragmentation: aState.mReflowStatus.IsFullyComplete(), !GetPrevInFlow(), and mReflowInput.AvailableBSize() != NS_UNCONSTRAINEDSIZE. Let’s look deeper into what these mean.

ReflowInput.AvailableBSize() != NS_UNCONSTRAINEDSIZE

As mentioned before, the AvailableBSize represents the amount of space before the bottom (or rather, block-end) edge of the fragmentation container (i.e. the page or column). Content that overflows this edge needs to be fragmented onto the next page or column.

As we go through Reflow(), we check whether we’re past this edge or not: if so, we either need to push the box to the next fragmentation container entirely, or we need to split it, pushing a box fragment containing the remaining content onto the next fragmentation container.

aState.mReflowStatus.IsFullyComplete()

One of the core outputs of the Reflow() function is an nsReflowStatus value. This indicates to the parent whether the box fit:

  • If a box is “fully complete”, that means both the box itself and all of its contents fit.
  • If it’s “incomplete”, that means the box didn't fit and needs to either fragment or be pushed onto the next page/column.
  • If it’s “overflow incomplete”, that means the box itself fits, but it has overflowing content that didn’t all fit.
!GetPrevInFlow()

As mentioned before, frames technically represent box fragments. So when a box gets split, another frame is generated, and it’s chained to the original box using the “NextInFlow” and “PrevInFlow” pointers. (See nsSplittableFrame.) If a box has a GetPrevInFlow(), that means it was split, and has a previous fragment.

Avoiding Fragmentation Complications

This patch’s approach to fragmentation is very dumb:

  • It bails out of alignment if the block or its contents are fragmented (aState.mReflowStatus.IsFullyComplete() && !GetPrevInFlow()).
  • It limits any shift values that would push its contents to overflow the fragmentainer boundary (and thus require them to be fragmented where they wouldn’t be if alignment weren’t applied).

The first task is easy: if we’re being fragmented, we set the desired shift value to zero.

But doing this second task in a way that is fragmentation-aware, and handles dynamic changes properly, is not so simple.

nsBlockFrame::AlignContent() Review
+  // Calculate shift
+  nscoord shift = 0;
+  WritingMode wm = aState.mReflowInput.GetWritingMode();
+  if ((isCentered || isEndAlign) && !mLines.empty() &&
+      aState.mReflowStatus.IsFullyComplete() && !GetPrevInFlow()) {
...
+    // Handle limitations due to fragmentation
+    if (availB != NS_UNCONSTRAINEDSIZE) {
+      nscoord maxShift = availB - aState.mOverflowBCoord;
+      if (shift > maxShift) {
+        // avoid needing overflow fragmentation
+        shift = maxShift;
+        // trigger reflow if availB increases
+        aState.mFlags.mHasAlignContentOverflow = true;
+      }
+    }
+    // XXX Should also floor negative shift by distance to top of page
+    //     so it doesn't get clipped when printing.
+  }
+  // else: zero shift if start-aligned or if fragmented

The second task is handled by the if (shift > maxShift) { clause. But as input to maxShift we don’t need to just know the bottom edge of the measured content, the way ComputeFinalSize() does. We need to know the bottom edge of all of the content including overflowing content.

Consider a zero-height box containing 15 lines of content. If we push it down too far, those 15 lines of content will overflow the edge of the page.

But nsBlockFrame doesn’t currently track the bottom edge of the scrollable overflow. (It only tracks the bounds of ink overflow, which it needs to know for painting.)

Introducing BlockReflowState.mOverflowBCoord

In order to reference the limits of the block’s potentially overflowing content, we add a new variable to BlockReflowState to track: mOverflowBCoord:

@@ -338,16 +357,19 @@ class BlockReflowState {
   // The current block-direction coordinate in the block
   nscoord mBCoord;

+  // The farthest child overflow coordinate in the block so far
+  nscoord mOverflowBCoord;
+
   // mBlock's computed logical border+padding with pre-reflow skip sides applied
   // (See the constructor and nsIFrame::PreReflowBlockLevelLogicalSkipSides).
   LogicalMargin mBorderPadding;

   // The overflow areas of all floats placed so far
   OverflowAreas mFloatOverflowAreas;

Next we need to update mOverflowBCoord as we iterate over the content during Reflow(). This happens in two places:

Update for lines that are being reflowed this time:
@@ -3444,16 +3525,22 @@ void nsBlockFrame::ReflowLine(BlockReflo
     }
   }

+  mozilla::LogicalRect overflowArea =
+      aLine->GetOverflowArea(mozilla::OverflowType::Scrollable,
+                             aLine->mWritingMode, aState.mContainerSize);
+  aState.mOverflowBCoord =
+      std::max(aState.mOverflowBCoord, overflowArea.BEnd(aLine->mWritingMode));
+
   aLine->ClearMovedFragments();
 }

 nsIFrame* nsBlockFrame::PullFrame(BlockReflowState& aState,
                                   LineIterator aLine) {
Update for lines that are not being reflowed this time:
@@ -3022,16 +3107,22 @@ void nsBlockFrame::ReflowDirtyLines(Bloc
       if (deltaBCoord != 0) {
         SlideLine(aState, line, deltaBCoord);
       } else {
         repositionViews = true;
       }

+      mozilla::LogicalRect overflowArea =
+          line->GetOverflowArea(mozilla::OverflowType::Scrollable,
+                                line->mWritingMode, aState.mContainerSize);
+      aState.mOverflowBCoord = std::max(aState.mOverflowBCoord,
+                                        overflowArea.BEnd(line->mWritingMode));
+
       NS_ASSERTION(!line->IsDirty() || !line->HasFloats(),
                    "Possibly stale float cache here!");
       if (willReflowAgain && line->IsBlock()) {
         // If we're going to reflow everything again, and this line is a block,

Tracking reflow needs with AlignContentOverflow

Remember that in AlignContent() we are limiting the amount of shift in order to prevent overflowing content from overflowing the edge of the fragmentainer.

For this to work correctly, we need to trigger reflow on a child not only when ReflowInput.AvailableBSize decreases such that its scrollable overflow no longer fits (which Reflow() already does by checking if a child’s bounds intersect the new AvailableBSize), but also when ReflowInput.AvailableBSize increases! Because in that case, we need to adjust how much we limit the alignment shift.

To do this, we track boxes that might need this special reflow by flagging them specially when we hit this case in AlignContent():

+    // Handle limitations due to fragmentation
+    if (availB != NS_UNCONSTRAINEDSIZE) {
+      nscoord maxShift = availB - aState.mOverflowBCoord;
+      if (shift > maxShift) {
+        // avoid needing overflow fragmentation
+        shift = maxShift;
+        // trigger reflow if availB increases
+        aState.mFlags.mHasAlignContentOverflow = true;
+      }
+    }

Next, we need to store this flag between Reflow() calls, since BlockReflowState is destroyed at the end of Reflow().

The obvious thing to do is use a bit flag. But there’s a limited number of bit flags on nsIFrame, and they’re all used up. And we can’t increase the number of bit flags, because that would inflate every fragment of every box on the page, and that’s not an acceptable memory cost.

The other obvious thing to do is to use a frame property, like we did for AlignContentShift. But the problem with that is we need to check for it on every child during reflow (even when that child itself isn’t being reflowed), and that’s not a fast lookup.

So instead what we’re going to do is re-use one of the existing bits. It’s doing something very similar right now: triggering a special reflow on block children that have clearance.

First, we rename the relevant frame state bit:

diff --git a/layout/generic/nsFrameStateBits.h b/layout/generic/nsFrameStateBits.h
--- a/layout/generic/nsFrameStateBits.h
+++ b/layout/generic/nsFrameStateBits.h
@@ -536,20 +536,18 @@ FRAME_STATE_BIT(Block, 23, NS_BLOCK_FLOA
 FRAME_STATE_BIT(Block, 25, NS_BLOCK_HAS_OVERFLOW_LINES)

 FRAME_STATE_BIT(Block, 26, NS_BLOCK_HAS_OVERFLOW_OUT_OF_FLOWS)

-// Set on any block that has descendant frames in the normal
-// flow with 'clear' set to something other than 'none'
-// (including <BR CLEAR="..."> frames)
-FRAME_STATE_BIT(Block, 27, NS_BLOCK_HAS_CLEAR_CHILDREN)
+// Set on any block that needs unoptimized reflow for itself or a descendant
+FRAME_STATE_BIT(Block, 27, NS_BLOCK_HAS_SPECIAL_CHILDREN)

 // NS_BLOCK_CLIP_PAGINATED_OVERFLOW is only set in paginated prescontexts, on
 // blocks which were forced to not have scrollframes but still need to clip
 // the display of their kids.
 FRAME_STATE_BIT(Block, 28, NS_BLOCK_CLIP_PAGINATED_OVERFLOW)

Because it's now serving two purposes, and we need more information to distinguish them, we also declare two distinct frame properties. (Since we only check these if the frame state bit is flipped, the lookup won’t slow us down in nsBlockFrame::Reflow().)

This segment declares the new frame property HasClearChildren:

@@ -682,16 +692,21 @@ class nsBlockFrame : public nsContainerF
   /** reflow all lines that have been marked dirty */
   void ReflowDirtyLines(BlockReflowState& aState);

+  // Set on any block that has descendant frames in the normal
+  // flow with 'clear' set to something other than 'none'
+  // (including <BR CLEAR="..."> frames)
+  NS_DECLARE_FRAME_PROPERTY_SMALL_VALUE(HasClearChildren, bool)
+
   /** Mark a given line dirty due to reflow being interrupted on or before it */
   void MarkLineDirtyForInterrupt(nsLineBox* aLine);

And this segment we reviewed earlier declares the new frame property AlignContentOverflow:

   /**
+   * Calculates the necessary shift to honor 'align-content' and applies it.
+   */
+  void AlignContent(BlockReflowState& aState, ReflowOutput& aMetrics,
+                    nscoord* aBEndEdgeOfChildren);
+  // Stash the effective align-content shift value between reflows
+  NS_DECLARE_FRAME_PROPERTY_SMALL_VALUE(AlignContentShift, nscoord)
+  // Trigger reflow when alignment was limited due to limited AvailableBSize
+  NS_DECLARE_FRAME_PROPERTY_SMALL_VALUE(AlignContentOverflow, bool)

To track these statuses more easily, we set up some infrastructure in BlockReflowState: two flags on the state’s mFlags member, initialization in the constructor, and transfer to storage on the frame in the destructor.

This segment declares and initializes mHasClearChildren and mHasAlignContentOverflow on BockReflowState::Flags:

diff --git a/layout/generic/BlockReflowState.h b/layout/generic/BlockReflowState.h
--- a/layout/generic/BlockReflowState.h
+++ b/layout/generic/BlockReflowState.h
@@ -33,16 +33,18 @@ class BlockReflowState {
     Flags()
         : mIsBStartMarginRoot(false),
           mIsBEndMarginRoot(false),
           mShouldApplyBStartMargin(false),
           mHasLineAdjacentToTop(false),
           mBlockNeedsFloatManager(false),
           mIsLineLayoutEmpty(false),
           mIsFloatListInBlockPropertyTable(false),
+          mHasClearChildren(false),
+          mHasAlignContentOverflow(false),
           mCanHaveOverflowMarkers(false) {}
@@ -84,28 +86,43 @@ class BlockReflowState {
     // Set when our mPushedFloats list is stored on the block's property table.
     bool mIsFloatListInBlockPropertyTable : 1;

+    // Set when we have descendants with 'clear'
+    bool mHasClearChildren : 1;
+
+    // Set when we truncated alignment to avoid overflowing the fragmentainer
+    bool mHasAlignContentOverflow : 1;
+
     // Set when we need text-overflow or -webkit-line-clamp processing.
     bool mCanHaveOverflowMarkers : 1;
   };

This segment defines the BlockReflowState destructor to manage setting and removing the NS_BLOCK_HAS_SPECIAL_CHILDREN frame bit and the HasClearChildren and AlignContentOverflow frame properties based on the BlockReflowState’s mHasClearChildren and mHasAlignContentOverflowflags:

+BlockReflowState::~BlockReflowState() {
+  // annotate conditions that require Reflow to inspect deeper
+  if (mFlags.mHasClearChildren || mFlags.mHasAlignContentOverflow) {
+    // this lookup is frequent, so we note it in frame bits
+    mBlock->AddStateBits(NS_BLOCK_HAS_SPECIAL_CHILDREN);
+    // positive cases are rare, so distinguish in frame properties
+    if (mFlags.mHasClearChildren)
+      mBlock->SetProperty(nsBlockFrame::HasClearChildren(), true);
+    if (mFlags.mHasAlignContentOverflow)
+      mBlock->SetProperty(nsBlockFrame::AlignContentOverflow(), true);
+  } else if (mBlock->HasAnyStateBits(NS_BLOCK_HAS_SPECIAL_CHILDREN)) {
+    mBlock->RemoveStateBits(NS_BLOCK_HAS_SPECIAL_CHILDREN);
+    mBlock->RemoveProperty(nsBlockFrame::HasClearChildren());
+    mBlock->RemoveProperty(nsBlockFrame::AlignContentOverflow());
+  }
+}
+

Next we need to update the nsBlockFrame call sites to use the new flag system. We’ll do that by refactoring the existing LineHasClear() pattern to use a new BlockReflowState::CaptureSpecialReflowNeeds() method.

This segment updates nsBlockFrame::ReflowDirtyLines() to use the new special reflow flagging system:

@@ -2614,17 +2698,18 @@ static bool LinesAreEmpty(const nsLineLi
 void nsBlockFrame::ReflowDirtyLines(BlockReflowState& aState) {
   bool keepGoing = true;
   bool repositionViews = false;  // should we really need this?
-  bool foundAnyClears = aState.mTrailingClearFromPIF != StyleClear::None;
+  aState.mFlags.mHasClearChildren =
+      aState.mTrailingClearFromPIF != StyleClear::None;
   bool willReflowAgain = false;

@@ -2708,17 +2793,17 @@ void nsBlockFrame::ReflowDirtyLines(Bloc
     // This really sucks, but we have to look inside any blocks that have clear
     // elements inside them.
     // XXX what can we do smarter here?
     if (!line->IsDirty() && line->IsBlock() &&
-        line->mFirstChild->HasAnyStateBits(NS_BLOCK_HAS_CLEAR_CHILDREN)) {
+        line->mFirstChild->HasAnyStateBits(NS_BLOCK_HAS_SPECIAL_CHILDREN)) {
       line->MarkDirty();
     }

@@ -3069,19 +3160,17 @@ void nsBlockFrame::ReflowDirtyLines(Bloc
     if (line->HasFloatClearTypeAfter()) {
       inlineFloatClearType = line->FloatClearTypeAfter();
     }

-    if (LineHasClear(line.get())) {
-      foundAnyClears = true;
-    }
+    aState.CaptureSpecialReflowNeeds(line.get());

     DumpLine(aState, line, deltaBCoord, -1);
@@ -3250,19 +3339,17 @@ void nsBlockFrame::ReflowDirtyLines(Bloc
             break;
           }

-          if (LineHasClear(line.get())) {
-            foundAnyClears = true;
-          }
+          aState.CaptureSpecialReflowNeeds(line.get());

           if (aState.mPresContext->CheckForInterrupt(this)) {
             MarkLineDirtyForInterrupt(line);
@@ -3321,22 +3408,16 @@ void nsBlockFrame::ReflowDirtyLines(Bloc
   if (LinesAreEmpty(mLines) && ShouldHaveLineIfEmpty()) {
     aState.mBCoord += aState.mMinLineHeight;
   }

-  if (foundAnyClears) {
-    AddStateBits(NS_BLOCK_HAS_CLEAR_CHILDREN);
-  } else {
-    RemoveStateBits(NS_BLOCK_HAS_CLEAR_CHILDREN);
-  }
-

This segment removes the old LineHasClear() helper function:

@@ -2553,25 +2646,16 @@ void nsBlockFrame::PropagateFloatDamage(
-static bool LineHasClear(nsLineBox* aLine) {
-  return aLine->IsBlock()
-             ? (aLine->HasForcedLineBreakBefore() ||
-                aLine->mFirstChild->HasAnyStateBits(
-                    NS_BLOCK_HAS_CLEAR_CHILDREN) ||
-                !nsBlockFrame::BlockCanIntersectFloats(aLine->mFirstChild))
-             : aLine->HasFloatClearTypeAfter();
-}
-

This segment defines the new CaptureSpecialReflowNeeds() helper function:

+
+void BlockReflowState::CaptureSpecialReflowNeeds(nsLineBox* aLine) {
+  if (aLine->IsBlock()) {
+    nsIFrame* child = aLine->mFirstChild;
+    bool hasClear = false;
+    if (child->HasAnyStateBits(NS_BLOCK_HAS_SPECIAL_CHILDREN)) {
+      mFlags.mHasAlignContentOverflow =
+          child->GetProperty(nsBlockFrame::AlignContentOverflow());
+      hasClear = child->GetProperty(nsBlockFrame::HasClearChildren());
+    }
+    mFlags.mHasClearChildren = hasClear || aLine->HasForcedLineBreakBefore() ||
+                               !nsBlockFrame::BlockCanIntersectFloats(child);
+  } else {
+    mFlags.mHasClearChildren = aLine->HasFloatClearTypeAfter();
+  }
+}

And of course we also need to declare the new CaptureSpecialReflowNeeds() helper function in the BlockReflowState class definition:

@@ -232,16 +249,18 @@ class BlockReflowState {
       mLineNumber++;
     }
   }

+  void CaptureSpecialReflowNeeds(nsLineBox* aLine);
+
   //----------------------------------------

And with that bit of refactoring, we’ve set up the proper reflow triggers for handling dynamic changes in a fragmentation context when applying align-content to blocks that contain overflowing content... because in a browser engine, you have to handle all of the weird cases!

This concludes our tour of the patch. I hope you got a chance to learn a bit about Gecko—and if you think this kind of coding is fun, I highly recommend joining a browser engine team. :)