Subscribe on changes!

Scoped style attribute not attached to selector key if it's a pseudo-class/element

avatar
Jul 28th 2023

Vue version

3.3.9

Link to minimal reproduction

https://play.vuejs.org example

Steps to reproduce

  1. Create a scoped style block.
  2. Add a rule set with a selector which has any ancestor selectors and a bare pseudo-class (but not :is() or :where()) as the key (e.g. div :not(.test) not div *:not(.test)).

Example:

<style scoped>
div :not(.test) {
  color: tomato;
}

div :required {
  color: tomato;
}

/* workaround: add `*` to the pseudo class */
div *:required {
  color: tomato;
}
</style>

What is expected?

The generated CSS rule set's selector list is scoped to the key of the selector (i.e. div [data-v-7ba5bd90]:not(.test)) even if the key is a bare pseudo-class or pseudo-element (except in the case of :deep).

div [data-v-7ba5bd90]:not(.test) {
  color: tomato;
}

div [data-v-7ba5bd90]:required {
  color: tomato;
}

div *[data-v-7ba5bd90]:required {
  color: tomato;
}

What is actually happening?

The generated CSS rule set's selector list is scoped to the last selector part that is not a bare pseudo-class (i.e. div[data-v-7ba5bd90] :not(.test)).

div[data-v-7ba5bd90] :not(.test) {
  color: tomato;
}

div[data-v-7ba5bd90] :required {
  color: tomato;
}

div *[data-v-7ba5bd90]:required {
  color: tomato;
}

This leads to styles unintentionally leaking out of the scoped tree.

System Info

Binaries:
    Node: 19.1.0 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.19 - C:\Program Files\nodejs\yarn.CMD
    npm: 9.8.0 - ~\dev\package\node_modules\.bin\npm.CMD
    pnpm: 8.5.1 - C:\Program Files\nodejs\pnpm.CMD
  Browsers:
    Edge: Spartan (44.22621.1992.0), Chromium (115.0.1901.183)
    Internet Explorer: 11.0.22621.1

Any additional comments?

  • This seems to be true for most pseudo-classes and pseudo-elements, but isn't true for :is(), :where(), and the special case :deep()
  • Initially posted as a question here: https://github.com/vuejs/core/discussions/8800
  • The current behavior is unexpected to me when writing scoped styles as it easily allows styles to leak out of the scoped tree (e.g. when using slots)
avatar
Nov 27th 2023

This is as expected (align with the original behavior of :not or :required).

div [data-v-7ba5bd90]:not(.test) {
  color: tomato;
}

div[data-v-7ba5bd90] :not(.test) {
  color: tomato;
}

The above two rules are different. Whether we use scoped or not, we cannot change its original behavior.

avatar
Nov 27th 2023

I understand that div [data-v-7ba5bd90]:not(.test) and div[data-v-7ba5bd90] :not(.test) are different and that’s why the behavior of scoped styles with regards to pseudo classes is unexpected to me.

The two selectors div :not(.test) and div *:not(.test) are equivalent in all but structure. They have the same specificity and select the same nodes. I would therefore expect them to behave the same using Vue scoped styles, but they don’t. They generate different selectors not just in structure but also meaning.

div *:not(.test) behaves well, it generates div *[data-v-7ba5bd90]:not(.test). However, div :not(.test) doesn’t behave well: it generates div[data-v-7ba5bd90] :not(.test) when it should generate div [data-v-7ba5bd90]:not(.test).

That may lead to problems because it allows styles to be applied to a tree that’s not part of the component's scope. In other words, div :not(.test) behaves like div :deep(:not(.test)).

avatar
Nov 27th 2023

I think this is a bug. The data-v-* attribute selector should be added to the selector subjects.

avatar
Nov 27th 2023

A few test cases for packages/compiler-sfc/__tests__/compileStyle.spec.ts that currently fail:

describe('SFC scoped CSS', () => {
  test.each([
    {
      message: 'pseudo class with class',
      source: `.foo:after { color: red; }`,
      expected: `.foo:after[data-v-test] { color: red;`
    },
    {
      message: 'bare pseudo class at root',
      source: `:after { color: red; }`,
      expected: `:after[data-v-test] { color: red;`
    },
    {
      message: 'bare pseudo class as descendent',
      source: `div :required { color: red; }`,
      expected: `div :required[data-v-test] { color: red;`
    },
    {
      message: 'pseudo class with tag as descendent',
      source: `div input:required { color: red; }`,
      expected: `div input:required[data-v-test] { color: red;`
    },
    {
      message: ':not pseudo class',
      source: `input:not(.error) { color: red; }`,
      expected: `input:not(.error)[data-v-test] { color: red;`
    },
  ])('$message', ({ source, expected }) => {
    expect(compileScoped(source)).toMatch(expected)
  })
})

Relevant code: https://github.com/vuejs/core/blob/069f838691b2238f31f4237e8412d9ff12921995/packages/compiler-sfc/src/style/pluginScoped.ts#L173-L175

I think the following change would fix this:

-     if (n.type !== 'pseudo' && n.type !== 'combinator') {
+     if (n.type !== 'combinator') {

It comes with the caveat that the generated [data-v-...] attribute would be come after the pseudo class (e.g. input:not(.error) { color: red; } produces input:not(.error)[data-v-test] and not input[data-v-test]:not(.error)). However, this should be a cosmetic difference only as the selectors are correct and valid both ways.

I'd be happy to contribute this if you want. I’m not totally sure if the approach is correct though.

avatar
Nov 28th 2023

@kleinfreund uh, I got your point. PR welcome