Scoped style attribute not attached to selector key if it's a pseudo-class/element
Vue version
3.3.9
Link to minimal reproduction
https://play.vuejs.org example
Steps to reproduce
- Create a scoped style block.
- 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)
notdiv *: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)
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.
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))
.
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.