Bug 1305957 part 5 - Add implementation of scroll anchor selection and invalidation. r=hiro,dbaron,dholbert
authorRyan Hunt <rhunt@eqrion.net>
Tue, 27 Nov 2018 15:45:16 -0600
changeset 510489 b6c42df7ce091d7f2417e13203a50c60cd144571
parent 510488 52a6a35a238d2ea87c60cc387d12e7348fcf821a
child 510490 462f25bfdc6514d182cd150c8142a03fb6078316
push id10547
push userffxbld-merge
push dateMon, 21 Jan 2019 13:03:58 +0000
treeherdermozilla-beta@24ec1916bffe [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewershiro, dbaron, dholbert
bugs1305957
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1305957 part 5 - Add implementation of scroll anchor selection and invalidation. r=hiro,dbaron,dholbert This commit implements candidate selection for a scroll frame using a frame tree traversal. It roughly tries to follow the algorithm given in the scroll anchoring draft specification, adapted to operate on the frame tree [1]. Some details, such as not selecting an anchor if the user hasn't scrolled are not currently in the specification but will be to match Blink's implementation. Once a scroll anchor has been selected, we maintain a bit on it and its ancestor frame's states. This is used in a later commit to detect changes to position during a reflow so the scroll frame can perform an adjustment. A scroll anchor will be invalidated when the user scrolls the frame or the scroll anchor is destroyed. Later commits will add logic to drive selection and invalidation appropriately. [1] https://drafts.csswg.org/css-scroll-anchoring/#anchor-node-selection Differential Revision: https://phabricator.services.mozilla.com/D13268
layout/base/nsLayoutUtils.cpp
layout/base/nsLayoutUtils.h
layout/generic/ScrollAnchorContainer.cpp
layout/generic/ScrollAnchorContainer.h
layout/generic/nsFrame.cpp
layout/generic/nsGfxScrollFrame.cpp
layout/generic/nsIFrame.h
--- a/layout/base/nsLayoutUtils.cpp
+++ b/layout/base/nsLayoutUtils.cpp
@@ -1485,21 +1485,21 @@ bool nsLayoutUtils::IsAncestorFrameCross
   for (const nsIFrame* f = aFrame; f != aCommonAncestor;
        f = GetCrossDocParentFrame(f)) {
     if (f == aAncestorFrame) return true;
   }
   return aCommonAncestor == aAncestorFrame;
 }
 
 // static
-bool nsLayoutUtils::IsProperAncestorFrame(nsIFrame* aAncestorFrame,
-                                          nsIFrame* aFrame,
-                                          nsIFrame* aCommonAncestor) {
+bool nsLayoutUtils::IsProperAncestorFrame(const nsIFrame* aAncestorFrame,
+                                          const nsIFrame* aFrame,
+                                          const nsIFrame* aCommonAncestor) {
   if (aFrame == aAncestorFrame) return false;
-  for (nsIFrame* f = aFrame; f != aCommonAncestor; f = f->GetParent()) {
+  for (const nsIFrame* f = aFrame; f != aCommonAncestor; f = f->GetParent()) {
     if (f == aAncestorFrame) return true;
   }
   return aCommonAncestor == aAncestorFrame;
 }
 
 // static
 int32_t nsLayoutUtils::DoCompareTreePosition(
     nsIContent* aContent1, nsIContent* aContent2, int32_t aIf1Ancestor,
--- a/layout/base/nsLayoutUtils.h
+++ b/layout/base/nsLayoutUtils.h
@@ -523,18 +523,19 @@ class nsLayoutUtils {
 
   /**
    * IsProperAncestorFrame checks whether aAncestorFrame is an ancestor
    * of aFrame and not equal to aFrame.
    * @param aCommonAncestor nullptr, or a common ancestor of aFrame and
    * aAncestorFrame. If non-null, this can bound the search and speed up
    * the function
    */
-  static bool IsProperAncestorFrame(nsIFrame* aAncestorFrame, nsIFrame* aFrame,
-                                    nsIFrame* aCommonAncestor = nullptr);
+  static bool IsProperAncestorFrame(const nsIFrame* aAncestorFrame,
+                                    const nsIFrame* aFrame,
+                                    const nsIFrame* aCommonAncestor = nullptr);
 
   /**
    * Like IsProperAncestorFrame, but looks across document boundaries.
    *
    * Just like IsAncestorFrameCrossDoc, except that it returns false when
    * aFrame == aAncestorFrame.
    */
   static bool IsProperAncestorFrameCrossDoc(
--- a/layout/generic/ScrollAnchorContainer.cpp
+++ b/layout/generic/ScrollAnchorContainer.cpp
@@ -1,27 +1,31 @@
 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* vim: set ts=8 sts=2 et sw=2 tw=80: */
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #include "ScrollAnchorContainer.h"
 
+#include "mozilla/StaticPrefs.h"
 #include "nsGfxScrollFrame.h"
 #include "nsLayoutUtils.h"
 
 #define ANCHOR_LOG(...)
 // #define ANCHOR_LOG(...) printf_stderr("ANCHOR: " __VA_ARGS__)
 
 namespace mozilla {
 namespace layout {
 
 ScrollAnchorContainer::ScrollAnchorContainer(ScrollFrameHelper* aScrollFrame)
-    : mScrollFrame(aScrollFrame) {}
+    : mScrollFrame(aScrollFrame),
+      mAnchorNode(nullptr),
+      mLastAnchorPos(0, 0),
+      mAnchorNodeIsDirty(true) {}
 
 ScrollAnchorContainer::~ScrollAnchorContainer() {}
 
 ScrollAnchorContainer* ScrollAnchorContainer::FindFor(nsIFrame* aFrame) {
   aFrame = aFrame->GetParent();
   if (!aFrame) {
     return nullptr;
   }
@@ -35,10 +39,398 @@ ScrollAnchorContainer* ScrollAnchorConta
 }
 
 nsIFrame* ScrollAnchorContainer::Frame() const { return mScrollFrame->mOuter; }
 
 nsIScrollableFrame* ScrollAnchorContainer::ScrollableFrame() const {
   return Frame()->GetScrollTargetFrame();
 }
 
+/**
+ * Set the appropriate frame flags for a frame that has become or is no longer
+ * an anchor node.
+ */
+static void SetAnchorFlags(const nsIFrame* aScrolledFrame,
+                           nsIFrame* aAnchorNode, bool aInScrollAnchorChain) {
+  nsIFrame* frame = aAnchorNode;
+  while (frame && frame != aScrolledFrame) {
+    MOZ_ASSERT(
+        frame == aAnchorNode || !frame->IsScrollFrame(),
+        "We shouldn't select an anchor node inside a nested scroll frame.");
+
+    frame->SetInScrollAnchorChain(aInScrollAnchorChain);
+    frame = frame->GetParent();
+  }
+  MOZ_ASSERT(frame,
+             "The anchor node should be a descendant of the scroll frame");
+}
+
+/**
+ * Compute the scrollable overflow rect [1] of aCandidate relative to
+ * aScrollFrame with all transforms applied. The specification is also
+ * ambiguous about what can be selected as a scroll anchor, which makes
+ * the scroll anchoring bounding rect partially undefined [2]. This code
+ * attempts to match the implementation in Blink.
+ *
+ * [1]
+ * https://drafts.csswg.org/css-scroll-anchoring-1/#scroll-anchoring-bounding-rect
+ * [2] https://github.com/w3c/csswg-drafts/issues/3478
+ */
+static nsRect FindScrollAnchoringBoundingRect(const nsIFrame* aScrollFrame,
+                                              nsIFrame* aCandidate) {
+  MOZ_ASSERT(nsLayoutUtils::IsProperAncestorFrame(aScrollFrame, aCandidate));
+  if (aCandidate->GetContent()->IsText()) {
+    nsRect bounding;
+    for (nsIFrame* continuation = aCandidate->FirstContinuation(); continuation;
+         continuation = continuation->GetNextContinuation()) {
+      nsRect localRect =
+          continuation->GetScrollableOverflowRectRelativeToSelf();
+      nsRect transformed = nsLayoutUtils::TransformFrameRectToAncestor(
+          continuation, localRect, aScrollFrame);
+      bounding = bounding.Union(transformed);
+    }
+    return bounding;
+  }
+
+  nsRect localRect = aCandidate->GetScrollableOverflowRectRelativeToSelf();
+  nsRect transformed = nsLayoutUtils::TransformFrameRectToAncestor(
+      aCandidate, localRect, aScrollFrame);
+  return transformed;
+}
+
+void ScrollAnchorContainer::SelectAnchor() {
+  MOZ_ASSERT(mScrollFrame->mScrolledFrame);
+  MOZ_ASSERT(mAnchorNodeIsDirty);
+
+  if (!StaticPrefs::layout_css_scroll_anchoring_enabled()) {
+    return;
+  }
+
+  ANCHOR_LOG("Selecting anchor for %p with scroll-port [%d %d x %d %d].\n",
+             this, mScrollFrame->mScrollPort.x, mScrollFrame->mScrollPort.y,
+             mScrollFrame->mScrollPort.width, mScrollFrame->mScrollPort.height);
+
+  const nsStyleDisplay* disp = Frame()->StyleDisplay();
+
+  // Don't select a scroll anchor if the scroll frame has `overflow-anchor:
+  // none`.
+  bool overflowAnchor =
+      disp->mOverflowAnchor == mozilla::StyleOverflowAnchor::Auto;
+
+  // Or if the scroll frame has not been scrolled from the logical origin. This
+  // is not in the specification [1], but Blink does this.
+  //
+  // [1] https://github.com/w3c/csswg-drafts/issues/3319
+  bool isScrolled = mScrollFrame->GetLogicalScrollPosition() != nsPoint();
+
+  // Or if there is perspective that could affect the scrollable overflow rect
+  // for descendant frames. This is not in the specification as Blink doesn't
+  // share this behavior with perspective [1].
+  //
+  // [1] https://github.com/w3c/csswg-drafts/issues/3322
+  bool hasPerspective = Frame()->ChildrenHavePerspective();
+
+  // Select a new scroll anchor
+  nsIFrame* oldAnchor = mAnchorNode;
+  if (overflowAnchor && isScrolled && !hasPerspective) {
+    ANCHOR_LOG("Beginning candidate selection.\n");
+    mAnchorNode = FindAnchorIn(mScrollFrame->mScrolledFrame);
+  } else {
+    if (!overflowAnchor) {
+      ANCHOR_LOG("Skipping candidate selection for `overflow-anchor: none`\n");
+    }
+    if (!isScrolled) {
+      ANCHOR_LOG("Skipping candidate selection for not being scrolled\n");
+    }
+    if (hasPerspective) {
+      ANCHOR_LOG(
+          "Skipping candidate selection for scroll frame with perspective\n");
+    }
+    mAnchorNode = nullptr;
+  }
+
+  // Update the anchor flags if needed
+  if (oldAnchor != mAnchorNode) {
+    ANCHOR_LOG("Anchor node has changed from (%p) to (%p).\n", oldAnchor,
+               mAnchorNode);
+
+    // Unset all flags for the old scroll anchor
+    if (oldAnchor) {
+      SetAnchorFlags(mScrollFrame->mScrolledFrame, oldAnchor, false);
+    }
+
+    // Set all flags for the new scroll anchor
+    if (mAnchorNode) {
+      // Anchor selection will never select a descendant of a different scroll
+      // frame, so we can set flags without conflicting with other scroll
+      // anchor containers.
+      SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, true);
+    }
+  } else {
+    ANCHOR_LOG("Anchor node has remained (%p).\n", mAnchorNode);
+  }
+
+  // Calculate the position to use for scroll adjustments
+  if (mAnchorNode) {
+    mLastAnchorPos =
+        FindScrollAnchoringBoundingRect(Frame(), mAnchorNode).TopLeft();
+    ANCHOR_LOG("Using last anchor position = [%d, %d].\n", mLastAnchorPos.x,
+               mLastAnchorPos.y);
+  } else {
+    mLastAnchorPos = nsPoint();
+  }
+
+  mAnchorNodeIsDirty = false;
+}
+
+void ScrollAnchorContainer::UserScrolled() { InvalidateAnchor(); }
+
+void ScrollAnchorContainer::InvalidateAnchor() {
+  if (!StaticPrefs::layout_css_scroll_anchoring_enabled()) {
+    return;
+  }
+
+  ANCHOR_LOG("Invalidating scroll anchor %p for %p.\n", mAnchorNode, this);
+
+  if (mAnchorNode) {
+    SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, false);
+  }
+  mAnchorNode = nullptr;
+  mAnchorNodeIsDirty = true;
+  mLastAnchorPos = nsPoint();
+}
+
+void ScrollAnchorContainer::Destroy() {
+  if (mAnchorNode) {
+    SetAnchorFlags(mScrollFrame->mScrolledFrame, mAnchorNode, false);
+  }
+  mAnchorNode = nullptr;
+  mAnchorNodeIsDirty = false;
+  mLastAnchorPos = nsPoint();
+}
+
+ScrollAnchorContainer::ExamineResult
+ScrollAnchorContainer::ExamineAnchorCandidate(nsIFrame* aFrame) const {
+#ifdef DEBUG_FRAME_DUMP
+  nsCString tag = aFrame->ListTag();
+  ANCHOR_LOG("\tVisiting frame=%s (%p).\n", tag.get(), aFrame);
+#else
+  ANCHOR_LOG("\t\tVisiting frame=%p.\n", aFrame);
+#endif
+
+  // Check if the author has opted out of scroll anchoring for this frame
+  // and its descendants.
+  const nsStyleDisplay* disp = aFrame->StyleDisplay();
+  if (disp->mOverflowAnchor == mozilla::StyleOverflowAnchor::None) {
+    ANCHOR_LOG("\t\tExcluding `overflow-anchor: none`.\n");
+    return ExamineResult::Exclude;
+  }
+
+  // Sticky positioned elements can move with the scroll frame, making them
+  // unsuitable scroll anchors. This isn't in the specification yet [1], but
+  // matches Blink's implementation.
+  //
+  // [1] https://github.com/w3c/csswg-drafts/issues/3319
+  if (aFrame->IsStickyPositioned()) {
+    ANCHOR_LOG("\t\tExcluding `position: sticky`.\n");
+    return ExamineResult::Exclude;
+  }
+
+  // The frame for a <br> element has a non-zero area, but Blink treats them
+  // as if they have no area, so exclude them specially.
+  if (aFrame->IsBrFrame()) {
+    ANCHOR_LOG("\t\tExcluding <br>.\n");
+    return ExamineResult::Exclude;
+  }
+
+  // Exclude frames that aren't accessible to content.
+  bool isChrome =
+      aFrame->GetContent() && aFrame->GetContent()->ChromeOnlyAccess();
+  bool isPseudo = aFrame->Style()->IsPseudoElement();
+  if (isChrome && !isPseudo) {
+    ANCHOR_LOG("\t\tExcluding chrome only content.\n");
+    return ExamineResult::Exclude;
+  }
+
+  // See if this frame could have its own anchor node. We could check
+  // IsScrollFrame(), but that would miss nsListControlFrame which is not a
+  // scroll frame, but still inherits from nsHTMLScrollFrame.
+  nsIScrollableFrame* scrollable = do_QueryFrame(aFrame);
+
+  // We don't allow scroll anchors to be selected inside of scrollable frames as
+  // it's not clear how an anchor adjustment should apply to multiple scrollable
+  // frames. Blink allows this to happen, but they're not sure why [1].
+  //
+  // We also don't allow scroll anchors to be selected inside of SVG as it uses
+  // a different layout model than CSS, and the specification doesn't say it
+  // should apply.
+  //
+  // [1] https://github.com/w3c/csswg-drafts/issues/3477
+  bool canDescend = !scrollable && !aFrame->IsSVGOuterSVGFrame();
+
+  // Check what kind of frame this is
+  bool isBlockOutside = aFrame->IsBlockOutside();
+  bool isText = aFrame->GetContent()->IsText();
+  bool isAnonBox = aFrame->Style()->IsAnonBox() && !isText;
+  bool isInlineOutside = aFrame->IsInlineOutside() && !isText;
+  bool isContinuation = !!aFrame->GetPrevContinuation();
+
+  // If the frame is anonymous or inline-outside, search its descendants for a
+  // scroll anchor.
+  if ((isAnonBox || isInlineOutside) && canDescend) {
+    ANCHOR_LOG(
+        "\t\tSearching descendants of anon or inline box (a=%d, i=%d).\n",
+        isAnonBox, isInlineOutside);
+    return ExamineResult::PassThrough;
+  }
+
+  // If the frame is not block-outside or a text node then exclude it.
+  if (!isBlockOutside && !isText) {
+    ANCHOR_LOG("\t\tExcluding non block-outside or text node (b=%d, t=%d).\n",
+               isBlockOutside, isText);
+    return ExamineResult::Exclude;
+  }
+
+  // Find the scroll anchoring bounding rect.
+  nsRect rect = FindScrollAnchoringBoundingRect(Frame(), aFrame);
+  ANCHOR_LOG("\t\trect = [%d %d x %d %d].\n", rect.x, rect.y, rect.width,
+             rect.height);
+
+  // Check if this frame is visible in the scroll port. This will exclude rects
+  // with zero sized area. The specification is ambiguous about this [1], but
+  // this matches Blink's implementation.
+  //
+  // [1] https://github.com/w3c/csswg-drafts/issues/3483
+  nsRect visibleRect;
+  if (!visibleRect.IntersectRect(rect, mScrollFrame->mScrollPort)) {
+    return ExamineResult::Exclude;
+  }
+
+  // At this point, if canDescend is true, we should only have visible
+  // non-anonymous frames that are either:
+  //   1. block-outside
+  //   2. text nodes
+  //
+  // It's not clear what the scroll anchoring bounding rect of elements that are
+  // block-outside should be when they are fragmented. For text nodes that are
+  // fragmented, it's specified that we need to consider the union of its line
+  // boxes.
+  //
+  // So for text nodes we handle them by including the union of line boxes in
+  // the bounding rect of the primary frame, and not selecting any
+  // continuations.
+  //
+  // For block-outside elements we choose to consider the bounding rect of each
+  // frame individually, allowing ourselves to descend into any frame, but only
+  // selecting a frame if it's not a continuation.
+  if (canDescend && isContinuation) {
+    ANCHOR_LOG("\t\tSearching descendants of a continuation.\n");
+    return ExamineResult::PassThrough;
+  }
+
+  // If this frame is fully visible, then select it as the scroll anchor.
+  if (visibleRect.IsEqualEdges(rect)) {
+    ANCHOR_LOG("\t\tFully visible, taking.\n");
+    return ExamineResult::Accept;
+  }
+
+  // If we can't descend into this frame, then select it as the scroll anchor.
+  if (!canDescend) {
+    ANCHOR_LOG("\t\tIntersects a frame that we can't descend into, taking.\n");
+    return ExamineResult::Accept;
+  }
+
+  // It must be partially visible and we can descend into this frame. Examine
+  // its children for a better scroll anchor or fall back to this one.
+  ANCHOR_LOG("\t\tIntersects valid candidate, checking descendants.\n");
+  return ExamineResult::Traverse;
+}
+
+nsIFrame* ScrollAnchorContainer::FindAnchorIn(nsIFrame* aFrame) const {
+  // Visit the child lists of this frame
+  for (nsIFrame::ChildListIterator lists(aFrame); !lists.IsDone();
+       lists.Next()) {
+    // Skip child lists that contain out-of-flow frames, we'll visit them by
+    // following placeholders in the in-flow lists so that we visit these
+    // frames in DOM order.
+    // XXX do we actually need to exclude kOverflowOutOfFlowList too?
+    if (lists.CurrentID() == FrameChildListID::kAbsoluteList ||
+        lists.CurrentID() == FrameChildListID::kFixedList ||
+        lists.CurrentID() == FrameChildListID::kFloatList ||
+        lists.CurrentID() == FrameChildListID::kOverflowOutOfFlowList) {
+      continue;
+    }
+
+    // Search the child list, and return if we selected an anchor
+    if (nsIFrame* anchor = FindAnchorInList(lists.CurrentList())) {
+      return anchor;
+    }
+  }
+
+  // The spec requires us to do an extra pass to visit absolutely positioned
+  // frames a second time after all the children of their containing block have
+  // been visited.
+  //
+  // It's not clear why this is needed [1], but it matches Blink's
+  // implementation, and is needed for a WPT test.
+  //
+  // [1] https://github.com/w3c/csswg-drafts/issues/3465
+  const nsFrameList& absPosList =
+      aFrame->GetChildList(FrameChildListID::kAbsoluteList);
+  if (nsIFrame* anchor = FindAnchorInList(absPosList)) {
+    return anchor;
+  }
+
+  return nullptr;
+}
+
+nsIFrame* ScrollAnchorContainer::FindAnchorInList(
+    const nsFrameList& aFrameList) const {
+  for (nsIFrame* child : aFrameList) {
+    // If this is a placeholder, try to follow it to the out of flow frame.
+    nsIFrame* realFrame = nsPlaceholderFrame::GetRealFrameFor(child);
+    if (child != realFrame) {
+      // If the out of flow frame is not a descendant of our scroll frame,
+      // then it must have a different containing block and cannot be an
+      // anchor node.
+      if (!nsLayoutUtils::IsProperAncestorFrame(Frame(), realFrame)) {
+        ANCHOR_LOG(
+            "\t\tSkipping out of flow frame that is not a descendant of the "
+            "scroll frame.\n");
+        continue;
+      }
+      ANCHOR_LOG("\t\tFollowing placeholder to out of flow frame.\n");
+      child = realFrame;
+    }
+
+    // Perform the candidate examination algorithm
+    ExamineResult examine = ExamineAnchorCandidate(child);
+
+    // See the comment before the definition of `ExamineResult` in
+    // `ScrollAnchorContainer.h` for an explanation of this behavior.
+    switch (examine) {
+      case ExamineResult::Exclude: {
+        continue;
+      }
+      case ExamineResult::PassThrough: {
+        nsIFrame* candidate = FindAnchorIn(child);
+        if (!candidate) {
+          continue;
+        }
+        return candidate;
+      }
+      case ExamineResult::Traverse: {
+        nsIFrame* candidate = FindAnchorIn(child);
+        if (!candidate) {
+          return child;
+        }
+        return candidate;
+      }
+      case ExamineResult::Accept: {
+        return child;
+      }
+    }
+  }
+  return nullptr;
+}
+
 }  // namespace layout
 }  // namespace mozilla
--- a/layout/generic/ScrollAnchorContainer.h
+++ b/layout/generic/ScrollAnchorContainer.h
@@ -2,16 +2,19 @@
 /* vim: set ts=8 sts=2 et sw=2 tw=80: */
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #ifndef mozilla_layout_ScrollAnchorContainer_h_
 #define mozilla_layout_ScrollAnchorContainer_h_
 
+#include "nsPoint.h"
+
+class nsIFrame;
 namespace mozilla {
 class ScrollFrameHelper;
 }  // namespace mozilla
 
 namespace mozilla {
 namespace layout {
 
 /**
@@ -28,28 +31,105 @@ class ScrollAnchorContainer final {
 
   /**
    * Returns the nearest scroll anchor container that could select aFrame as an
    * anchor node.
    */
   static ScrollAnchorContainer* FindFor(nsIFrame* aFrame);
 
   /**
+   * Returns the frame that is the selected anchor node or null if no anchor
+   * is selected.
+   */
+  nsIFrame* AnchorNode() const { return mAnchorNode; }
+
+  /**
    * Returns the frame that owns this scroll anchor container. This is always
    * non-null.
    */
   nsIFrame* Frame() const;
 
   /**
    * Returns the frame that owns this scroll anchor container as a scrollable
    * frame. This is always non-null.
    */
   nsIScrollableFrame* ScrollableFrame() const;
 
+  /**
+   * Find a suitable anchor node among the descendants of the scrollable frame.
+   * This should only be called after the scroll anchor has been invalidated.
+   */
+  void SelectAnchor();
+
+  /**
+   * Notify the scroll anchor container that its scroll frame has been
+   * scrolled by a user and should invalidate itself.
+   */
+  void UserScrolled();
+
+  /**
+   * Notify this scroll anchor container that its anchor node should be
+   * invalidated and recomputed at the next available opportunity.
+   */
+  void InvalidateAnchor();
+
+  /**
+   * Notify this scroll anchor container that it will be destroyed along with
+   * its parent frame.
+   */
+  void Destroy();
+
  private:
+  // Represents an assessment of a frame's suitability as a scroll anchor,
+  // from the scroll-anchoring spec's "candidate examination algorithm":
+  // https://drafts.csswg.org/css-scroll-anchoring-1/#candidate-examination
+  enum class ExamineResult {
+    // The frame is an excluded subtree or fully clipped and should be ignored.
+    // This corresponds with step 1 in the algorithm.
+    Exclude,
+    // This frame is an anonymous or inline box and its descendants should be
+    // searched to find an anchor node. If none are found, then continue
+    // searching. This is implied by the prologue of the algorithm, and
+    // should be made explicit in the spec [1].
+    //
+    // [1] https://github.com/w3c/csswg-drafts/issues/3489
+    PassThrough,
+    // The frame is partially visible and its descendants should be searched to
+    // find an anchor node. If none are found then this frame should be
+    // selected. This corresponds with step 3 in the algorithm.
+    Traverse,
+    // The frame is fully visible and should be selected as an anchor node. This
+    // corresponds with step 2 in the algorithm.
+    Accept,
+  };
+
+  ExamineResult ExamineAnchorCandidate(nsIFrame* aPrimaryFrame) const;
+
+  // Search a frame's children to find an anchor node. Returns the frame for a
+  // valid anchor node, if one was found in the frames descendants, or null
+  // otherwise.
+  nsIFrame* FindAnchorIn(nsIFrame* aFrame) const;
+
+  // Search a child list to find an anchor node. Returns the frame for a valid
+  // anchor node, if one was found in this child list, or null otherwise.
+  nsIFrame* FindAnchorInList(const nsFrameList& aFrameList) const;
+
   // The owner of this scroll anchor container
   ScrollFrameHelper* mScrollFrame;
+
+  // The anchor node that we will scroll to keep in the same relative position
+  // after reflows. This may be null if we were not able to select a valid
+  // scroll anchor
+  nsIFrame* mAnchorNode;
+
+  // The last position of the scroll anchor node relative to the scrollable
+  // frame. This is used for calculating the distance to scroll to keep the
+  // anchor node in the same relative position
+  nsPoint mLastAnchorPos;
+
+  // True if we should recalculate our anchor node at the next chance
+  bool mAnchorNodeIsDirty : 1;
 };
 
 }  // namespace layout
 }  // namespace mozilla
 
 #endif  // mozilla_layout_ScrollAnchorContainer_h_
--- a/layout/generic/nsFrame.cpp
+++ b/layout/generic/nsFrame.cpp
@@ -106,16 +106,17 @@
 #include "mozilla/LookAndFeel.h"
 #include "mozilla/MouseEvents.h"
 #include "mozilla/ServoStyleSet.h"
 #include "mozilla/ServoStyleSetInlines.h"
 #include "mozilla/css/ImageLoader.h"
 #include "mozilla/dom/TouchEvent.h"
 #include "mozilla/gfx/Tools.h"
 #include "mozilla/layers/WebRenderUserData.h"
+#include "mozilla/layout/ScrollAnchorContainer.h"
 #include "nsPrintfCString.h"
 #include "ActiveLayerTracker.h"
 
 #include "nsITheme.h"
 
 using namespace mozilla;
 using namespace mozilla::css;
 using namespace mozilla::dom;
@@ -722,16 +723,21 @@ void nsFrame::DestroyFrom(nsIFrame* aDes
     }
   }
 
   if (IsPrimaryFrame()) {
     // This needs to happen before we clear our Properties() table.
     ActiveLayerTracker::TransferActivityToContent(this, mContent);
   }
 
+  ScrollAnchorContainer* anchor = nullptr;
+  if (IsScrollAnchor(&anchor)) {
+    anchor->InvalidateAnchor();
+  }
+
   if (HasCSSAnimations() || HasCSSTransitions() ||
       EffectSet::GetEffectSet(this)) {
     // If no new frame for this element is created by the end of the
     // restyling process, stop animations and transitions for this frame
     RestyleManager::AnimationsWithDestroyedFrame* adf =
         presContext->RestyleManager()->GetAnimationsWithDestroyedFrame();
     // AnimationsWithDestroyedFrame only lives during the restyling process.
     if (adf) {
@@ -9138,16 +9144,38 @@ void nsIFrame::ComputePreserve3DChildren
         if (child->Extend3DContext(childDisp, child->StyleEffects())) {
           child->ComputePreserve3DChildrenOverflow(aOverflowAreas);
         }
       }
     }
   }
 }
 
+bool nsIFrame::IsScrollAnchor(ScrollAnchorContainer** aOutContainer) {
+  if (!mInScrollAnchorChain) {
+    return false;
+  }
+
+  ScrollAnchorContainer* container = ScrollAnchorContainer::FindFor(this);
+  if (container->AnchorNode() != this) {
+    return false;
+  }
+
+  if (aOutContainer) {
+    *aOutContainer = container;
+  }
+  return true;
+}
+
+bool nsIFrame::IsInScrollAnchorChain() const { return mInScrollAnchorChain; }
+
+void nsIFrame::SetInScrollAnchorChain(bool aInChain) {
+  mInScrollAnchorChain = aInChain;
+}
+
 uint32_t nsIFrame::GetDepthInFrameTree() const {
   uint32_t result = 0;
   for (nsContainerFrame* ancestor = GetParent(); ancestor;
        ancestor = ancestor->GetParent()) {
     result++;
   }
   return result;
 }
--- a/layout/generic/nsGfxScrollFrame.cpp
+++ b/layout/generic/nsGfxScrollFrame.cpp
@@ -2736,16 +2736,17 @@ void ScrollFrameHelper::ScrollToImpl(nsP
     // offset. Otherwise, if we perform calculations that depend on this
     // offset (e.g. by using nsIDOMWindowUtils.getVisualViewportOffset()
     // in chrome JS code) before it's updated by the next APZ repaint,
     // we could get incorrect results.
     presContext->PresShell()->SetVisualViewportOffset(pt, curPos);
   }
 
   ScrollVisual();
+  mAnchor.UserScrolled();
 
   bool schedulePaint = true;
   if (nsLayoutUtils::AsyncPanZoomEnabled(mOuter) &&
       !nsLayoutUtils::ShouldDisableApzForElement(content) &&
       gfxPrefs::APZPaintSkipping()) {
     // If APZ is enabled with paint-skipping, there are certain conditions in
     // which we can skip paints:
     // 1) If APZ triggered this scroll, and the tile-aligned displayport is
@@ -4706,16 +4707,18 @@ void ScrollFrameHelper::AppendAnonymousC
   }
 
   if (mResizerContent) {
     aElements.AppendElement(mResizerContent);
   }
 }
 
 void ScrollFrameHelper::Destroy(PostDestroyData& aPostDestroyData) {
+  mAnchor.Destroy();
+
   if (mScrollbarActivity) {
     mScrollbarActivity->Destroy();
     mScrollbarActivity = nullptr;
   }
 
   // Unbind the content created in CreateAnonymousContent later...
   aPostDestroyData.AddAnonymousContent(mHScrollbarContent.forget());
   aPostDestroyData.AddAnonymousContent(mVScrollbarContent.forget());
--- a/layout/generic/nsIFrame.h
+++ b/layout/generic/nsIFrame.h
@@ -110,16 +110,20 @@ class ServoRestyleState;
 class DisplayItemData;
 class EffectSet;
 
 namespace layers {
 class Layer;
 class LayerManager;
 }  // namespace layers
 
+namespace layout {
+class ScrollAnchorContainer;
+}  // namespace layout
+
 namespace dom {
 class Selection;
 }  // namespace dom
 
 }  // namespace mozilla
 
 //----------------------------------------------------------------------
 
@@ -561,17 +565,18 @@ class nsIFrame : public nsQueryFrame {
         mForceDescendIntoIfVisible(false),
         mBuiltDisplayList(false),
         mFrameIsModified(false),
         mHasOverrideDirtyRegion(false),
         mMayHaveWillChangeBudget(false),
         mIsPrimaryFrame(false),
         mMayHaveTransformAnimation(false),
         mMayHaveOpacityAnimation(false),
-        mAllDescendantsAreInvisible(false) {
+        mAllDescendantsAreInvisible(false),
+        mInScrollAnchorChain(false) {
     mozilla::PodZero(&mOverflow);
   }
 
   nsPresContext* PresContext() const { return Style()->PresContextForFrame(); }
 
   nsIPresShell* PresShell() const { return PresContext()->PresShell(); }
 
   /**
@@ -1842,16 +1847,34 @@ class nsIFrame : public nsQueryFrame {
    * Includes the overflow area of all descendants that participate in the
    * current 3d context into aOverflowAreas.
    */
   void ComputePreserve3DChildrenOverflow(nsOverflowAreas& aOverflowAreas);
 
   void RecomputePerspectiveChildrenOverflow(const nsIFrame* aStartFrame);
 
   /**
+   * Returns whether this frame is the anchor of some ancestor scroll frame. As
+   * this frame is moved, the scroll frame will apply adjustments to keep this
+   * scroll frame in the same relative position.
+   *
+   * aOutContainer will optionally be set to the scroll anchor container for
+   * this frame if this frame is an anchor.
+   */
+  bool IsScrollAnchor(
+      mozilla::layout::ScrollAnchorContainer** aOutContainer = nullptr);
+
+  /**
+   * Returns whether this frame is the anchor of some ancestor scroll frame, or
+   * has a descendant which is the scroll anchor.
+   */
+  bool IsInScrollAnchorChain() const;
+  void SetInScrollAnchorChain(bool aInChain);
+
+  /**
    * Returns the number of ancestors between this and the root of our frame tree
    */
   uint32_t GetDepthInFrameTree() const;
 
   /**
    * Event handling of GUI events.
    *
    * @param aEvent event structure describing the type of event and rge widget
@@ -4288,19 +4311,22 @@ class nsIFrame : public nsQueryFrame {
    *
    * This flag is conservative in that it might sometimes be false even if, in
    * fact, all descendants are invisible.
    * For example; an element is visibility:visible and has a visibility:hidden
    * child. This flag is stil false in such case.
    */
   bool mAllDescendantsAreInvisible : 1;
 
+  /**
+   * True if we are or contain the scroll anchor for a scrollable frame.
+   */
+  bool mInScrollAnchorChain : 1;
+
  protected:
-  // There is a 1-bit gap left here.
-
   // Helpers
   /**
    * Can we stop inside this frame when we're skipping non-rendered whitespace?
    *
    * @param aForward [in] Are we moving forward (or backward) in content order.
    *
    * @param aOffset [in/out] At what offset into the frame to start looking.
    * at offset was reached (whether or not we found a place to stop).