Skip to content

fix(compiler): do not scope nested compound selectors starting with & - #71152

Open
rene-schakmann wants to merge 1 commit into
angular:mainfrom
rene-schakmann:fix/host-nested-ampersand-71050
Open

rene-schakmann wants to merge 1 commit into
angular:mainfrom
rene-schakmann:fix/host-nested-ampersand-71050

Conversation

@rene-schakmann

Copy link
Copy Markdown

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe:

What is the current behavior?

Since d0d7f57 (#69885) nested rules are scoped, which also appends the content attribute to compound selectors starting with &. Under :host, & refers to the host element, which only carries the _nghost-* attribute, so the rule silently stops matching (regression in 22.2.0):

/* input */
:host { display: block; &.md { width: 70px; } }

/* 22.1.8 */
[_nghost-c1] { display: block; &.md { width: 70px; } }

/* 22.2.0 */
[_nghost-c1] { display: block; &.md[_ngcontent-c1] { width: 70px; } }

Issue Number: #71050

What is the new behavior?

A compound selector starting with & (e.g. &.md, &:hover, &[attr]) is left unscoped, because it can only match elements already matched by the scoped parent selector. Descendants are still scoped:

:host { &.md {} & .child {} &.md > .child {} }
/* => */
[a-host] { &.md {} & .child[contenta] {} &.md > .child[contenta] {} }

This also covers :host(.foo) and :host-context(.foo) parents. For non-host parents (e.g. .parent { &.modifier {} }) the output changes from &.modifier[contenta] to &.modifier, which matches the same elements since & already resolves to .parent[contenta]. The two existing expectations in nesting_spec.ts were updated accordingly. Selectors that only reference & inside a pseudo-class (e.g. .a:not(&)) are still scoped.

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

New tests added to packages/compiler/test/shadow_css/nesting_spec.ts for :host, :host(.foo), :host-context(.foo) and descendants of &. They fail without the change and pass with it.

🤖 Generated with Claude Code

@pullapprove
pullapprove Bot requested a review from kirjs October 3, 2026 03:55
@google-cla

This comment was marked as outdated.

@angular-robot angular-robot Bot added the area: compiler Issues related to `ngc`, Angular's template compiler label Oct 3, 2026
@ngbot ngbot Bot added this to the Backlog milestone Oct 3, 2026
@rene-schakmann
rene-schakmann force-pushed the fix/host-nested-ampersand-71050 branch from 82a128c to d6a053c Compare October 3, 2026 03:56
Since nested CSS rules are scoped, a nested compound selector starting with `&` was getting the
content attribute appended. For example, `:host { &.foo {} }` produced
`[_nghost-c1] { &.foo[_ngcontent-c1] {} }`. Because `&` refers to the host element there and the
host element doesn't carry its own content attribute, the rule stopped matching.

A compound selector starting with `&` can only match elements that are already matched by the
scoped parent selector, so it doesn't need to be scoped again. Descendants (e.g. `& .child`) are
still scoped.

Fixes angular#71050
@rene-schakmann
rene-schakmann force-pushed the fix/host-nested-ampersand-71050 branch from d6a053c to d941ebf Compare October 3, 2026 03:59

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: I found a regression introduced by this change!

The logic scopedP.startsWith('&') causes pseudo-classes like :is() or :where() to be completely skipped—leaving their inner contents unscoped—if they are part of a compound selector starting with &.

For example, consider this CSS:

:host {
  &:is(.foo, .bar > .baz) {
    color: red;
  }
}

Before this PR, shadow_css.ts specifically parsed :is() and :where() to recursively scope their contents (to ensure encapsulation for things like .bar > .baz).

However, with this PR, _pseudoFunctionAwareScopeSelectorPart sees the & prefix and fails the pseudoSelectorParts.join('') === selectorPart check. It falls back to calling _scopeSelectorPart('&:is(.foo, .bar > .baz)'). Because the selector starts with &, it returns the entire string completely unmodified!

Output with the PR (Regression)

[a-host] {
  &:is(.foo, .bar > .baz) { /* .foo and .bar are completely unscoped! */
    color: red;
  }
}

This breaks encapsulation for .foo and .bar since they will match globally instead of being scoped to the component.

To fix this, _pseudoFunctionAwareScopeSelectorPart needs to be aware of the & prefix so it can strip it, recursively scope the inner contents of :is() / :where(), and then prepend the & back.

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Here is a failing unit test that reproduces this regression. You can add this directly to packages/compiler/test/shadow_css/nesting_spec.ts.

Because of the startsWith('&') check, &:is(...) is returned entirely unscoped instead of recursively scoping the inner selectors .foo and .bar > .baz.

  it('should scope inner selectors inside pseudo-class functions chained to an ampersand', () => {
    const css = `
      :host {
        &:is(.foo, .bar > .baz) {
          color: red;
        }
      }
    `;

    // What we EXPECT: the inner selectors are correctly scoped to the component
    // What the PR currently PRODUCES: [a-host] { &:is(.foo, .bar > .baz) { ... } }
    const expected = `
      [a-host] {
        &:is(.foo[contenta], .bar[contenta] > .baz[contenta]) {
          color: red;
        }
      }
    `;

    const result = shim(css, 'contenta', 'a-host');
    expect(result).toEqualCss(expected);
  });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: compiler Issues related to `ngc`, Angular's template compiler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants