commit d1276e3d41983f9e7da6fdf40ec49c9e2b8976f0
parent ec3475dc469dfc5416bb2159b26a5c7ee9cfe05e
Author: Jari Jalkanen <jjalkanen@mozilla.com>
Date: Tue, 2 Dec 2025 19:29:07 +0000
Bug 1987086 - Handle anchor scopes with display: contents. r=layout-anchor-positioning-reviewers,layout-reviewers,dshin
Rebased version of D264040, with the test moved to WPT and my comments
fixed.
Co-authored-by: Emilio Cobos Álvarez <emilio@crisal.io>
Differential Revision: https://phabricator.services.mozilla.com/D274795
Diffstat:
2 files changed, 303 insertions(+), 17 deletions(-)
diff --git a/layout/base/AnchorPositioningUtils.cpp b/layout/base/AnchorPositioningUtils.cpp
@@ -61,40 +61,43 @@ bool IsAnchorInScopeForPositionedElement(const nsAtom* aName,
aPositionedFrame->GetParent()->GetContent();
auto getAnchorPosNearestScope =
- [&positionedContainingBlockContent](
- const nsAtom* aName, const nsIFrame* aFrame) -> const nsIContent* {
+ [&](const nsAtom* aName, const nsIFrame* aFrame) -> const nsIContent* {
// We need to traverse the DOM, not the frame tree, since `anchor-scope`
// may be present on elements with `display: contents` (in which case its
// frame is in the `::before` list and won't be found by walking the frame
// tree parent chain).
- for (const nsIContent* cp = aFrame->GetContent();
+ for (nsIContent* cp = aFrame->GetContent();
cp && cp != positionedContainingBlockContent;
cp = cp->GetFlattenedTreeParentElementForStyle()) {
- // TODO: The case when no frame is generated needs to be
- // handled, e.g. `display: contents`, see bug 1987086.
- const nsIFrame* f = cp->GetPrimaryFrame();
- if (!f) {
- continue;
- }
+ const auto* anchorScope = [&]() -> const StyleAnchorScope* {
+ const nsIFrame* f = nsLayoutUtils::GetStyleFrame(cp);
+ if (MOZ_LIKELY(f)) {
+ return &f->StyleDisplay()->mAnchorScope;
+ }
+ if (cp->AsElement()->IsDisplayContents()) {
+ const auto* style =
+ Servo_Element_GetMaybeOutOfDateStyle(cp->AsElement());
+ MOZ_ASSERT(style);
+ return &style->StyleDisplay()->mAnchorScope;
+ }
+ return nullptr;
+ }();
- const StyleAnchorScope& anchorScope = f->StyleDisplay()->mAnchorScope;
- if (anchorScope.IsNone()) {
+ if (!anchorScope || anchorScope->IsNone()) {
continue;
}
- if (anchorScope.IsAll()) {
+ if (anchorScope->IsAll()) {
return cp;
}
- MOZ_ASSERT(anchorScope.IsIdents());
- for (const StyleAtom& ident : anchorScope.AsIdents().AsSpan()) {
- const auto* id = ident.AsAtom();
- if (aName->Equals(id->GetUTF16String(), id->GetLength())) {
+ MOZ_ASSERT(anchorScope->IsIdents());
+ for (const StyleAtom& ident : anchorScope->AsIdents().AsSpan()) {
+ if (aName == ident.AsAtom()) {
return cp;
}
}
}
-
return nullptr;
};
diff --git a/testing/web-platform/tests/css/css-anchor-position/anchor-scope-display-contents.tentative.html b/testing/web-platform/tests/css/css-anchor-position/anchor-scope-display-contents.tentative.html
@@ -0,0 +1,283 @@
+<!DOCTYPE html>
+<title>CSS Anchor Positioning: Basic anchor-scope behavior when scope element has display:contents property</title>
+<link rel="help" href="https://drafts.csswg.org/css-anchor-position-1/#anchor-scope">
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/12783">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<!-- This test is based on anchor-scope-basic.html -->
+<style>
+ .scope-all { anchor-scope: all; display: contents; }
+ .scope-a { anchor-scope: --a; display: contents; }
+ .scope-b { anchor-scope: --b; ; display: contents; }
+ .scope-ab { anchor-scope: --a, --b; ; display: contents; }
+
+ .anchor-a { anchor-name: --a; }
+ .anchor-b { anchor-name: --b; }
+ .anchor-ab { anchor-name: --a, --b; }
+ .anchor-a, .anchor-b, .anchor-ab {
+ background: skyblue;
+ height: 10px;
+ }
+
+ .anchored-a { position-anchor: --a; }
+ .anchored-b { position-anchor: --b; }
+ .anchored-a, .anchored-b {
+ position: absolute;
+ top: anchor(bottom);
+ left: anchor(left);
+ width: 5px;
+ height: 5px;
+ background: coral;
+ }
+
+ /* Containing block */
+ main {
+ position: relative;
+ width: 100px;
+ height: 100px;
+ border: 1px solid black;
+ }
+</style>
+<script>
+ function inflate(t, template_element) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template_element.content.cloneNode(true));
+ }
+</script>
+
+<main id=main>
+</main>
+
+<template id=test_inclusive_subtree>
+ <div class="scope-a anchor-a"> <!--A-->
+ <div class=anchored-a></div>
+ </div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_inclusive_subtree);
+ // In this case, anchor scope with display:contents is ignored.
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '0px');
+ }, 'Can anchor to a name both defined and scoped by the same element');
+</script>
+
+<template id=test_skips_named_anchor_with_scope>
+ <div class="anchor-a"></div>
+ <div class="anchor-a"></div>
+ <div class="anchor-a"></div> <!--A-->
+ <div class="scope-a anchor-a"></div>
+ <div class=anchored-a></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_skips_named_anchor_with_scope);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '30px');
+ }, 'Sibling can not anchor into anchor-scope, even when anchor-name present');
+</script>
+
+<template id=test_scope_all_common_ancestor>
+ <div class=scope-all>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div><!--A-->
+ <div class=anchored-a></div>
+ </div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_all_common_ancestor);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '40px');
+ }, 'anchor-scope:all on common ancestor');
+</script>
+
+<template id=test_scope_named_common_ancestor>
+ <div class=scope-a>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div><!--A-->
+ <div class=anchored-a></div>
+ </div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_named_common_ancestor);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '40px');
+ }, 'anchor-scope:--a on common ancestor');
+</script>
+
+<template id=test_scope_all_sibling>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div><!--A-->
+ <div class=scope-all>
+ <div class=anchor-a></div>
+ <div class=anchor-a></div>
+ </div>
+ <div class=anchored-a></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_all_sibling);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '20px');
+ }, 'anchor-scope:all on sibling');
+</script>
+
+<template id=test_scope_all_multiple>
+ <div class=anchor-b></div><!--B-->
+ <div class=anchor-a></div><!--A-->
+ <div class=scope-all>
+ <div class=anchor-b></div>
+ <div class=anchor-a></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_all_multiple);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '20px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '10px');
+ }, 'anchor-scope:all scopes multiple names');
+</script>
+
+<template id=test_scope_ab>
+ <div class=anchor-b></div><!--B-->
+ <div class=anchor-a></div><!--A-->
+ <div class=scope-ab>
+ <div class=anchor-b></div>
+ <div class=anchor-a></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_ab);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '20px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '10px');
+ }, 'anchor-scope:--a,--b scopes --a and --b');
+</script>
+
+<template id=test_scope_a>
+ <div class=anchor-b></div>
+ <div class=anchor-a></div><!--A-->
+ <div class=scope-a>
+ <div class=anchor-b></div>
+ <div class=anchor-ab></div><!--B-->
+ <div class=anchor-a></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_a);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '20px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '40px');
+ }, 'anchor-scope:--a scopes only --a');
+</script>
+
+<template id=test_scope_b>
+ <div class=anchor-b></div><!--B-->
+ <div class=anchor-a></div>
+ <div class=scope-b>
+ <div class=anchor-a></div><!--A-->
+ <div class=anchor-b></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_scope_b);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '30px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '10px');
+ }, 'anchor-scope:--b scopes only --b');
+</script>
+
+<template id=test_out_of_flow_anchors>
+ <style>
+ .anchor-a, .anchor-b {
+ position: absolute;
+ width: 5px;
+ height: 5px;
+ }
+ </style>
+ <div class=anchor-b style='left:10px'></div>
+ <div class=anchor-a style='left:20px'></div><!--A-->
+ <div class=scope-a>
+ <div class=anchor-b style='left:30px'></div><!--B-->
+ <div class=anchor-a style='left:40px'></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_out_of_flow_anchors);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '5px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).left, '20px');
+
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '5px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).left, '30px');
+ }, 'anchor-scope:--a scopes only --a (out-of-flow anchors)');
+</script>
+
+<!-- Out-of-flow anchor within anchor-scope:--a -->
+<template id=test_mixed_flow_anchors>
+ <style>
+ .abs {
+ position: absolute;
+ width: 5px;
+ height: 5px;
+ }
+ </style>
+ <div class=anchor-b></div>
+ <div class=anchor-a></div><!--A-->
+ <div class=scope-a>
+ <div class=anchor-b></div><!--B-->
+ <div class='anchor-a abs' style='top:50px'></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_mixed_flow_anchors);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '20px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).left, '0px');
+
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '30px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).left, '0px');
+ }, 'anchor-scope:--a scopes only --a (both out-of-flow and in-flow anchors)');
+</script>
+
+<!-- In-flow anchor within anchor-scope:--a -->
+<template id=test_mixed_flow_anchors_reverse>
+ <style>
+ .abs {
+ position: absolute;
+ width: 5px;
+ height: 5px;
+ }
+ </style>
+ <div class=anchor-b></div>
+ <div class='anchor-a abs' style='top:50px'></div><!--A-->
+ <div class=scope-a>
+ <div class=anchor-b></div><!--B-->
+ <div class=anchor-a></div>
+ </div>
+ <div class=anchored-a></div>
+ <div class=anchored-b></div>
+</template>
+<script>
+ test((t) => {
+ inflate(t, test_mixed_flow_anchors_reverse);
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).top, '55px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-a')).left, '0px');
+
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).top, '20px');
+ assert_equals(getComputedStyle(main.querySelector('.anchored-b')).left, '0px');
+ }, 'anchor-scope:--a scopes only --a (both out-of-flow and in-flow anchors, reverse)');
+</script>