Skip to content

fix(compiler-sfc): scope nested rules under mixed :deep() selector lists - #15270

Open
ValentinYoushkevich wants to merge 5 commits into
vuejs:mainfrom
ValentinYoushkevich:fix/scoped-deep-selector-list
Open

fix(compiler-sfc): scope nested rules under mixed :deep() selector lists#15270
ValentinYoushkevich wants to merge 5 commits into
vuejs:mainfrom
ValentinYoushkevich:fix/scoped-deep-selector-list

Conversation

@ValentinYoushkevich

@ValentinYoushkevich ValentinYoushkevich commented Aug 11, 2026

Copy link
Copy Markdown

Closes #15205.

Problem

When a scoped rule has a comma-separated selector list where one member uses :deep(), and the rule contains nested rules, the scope id is placed wrongly for the plain members. Depending on where the :deep() member sits, a plain member either loses the id entirely or keeps it while its nested rules go unscoped.

__deep is stored on the rule, but "is this deep?" is a property of an individual list member. Two things are inherently per-rule and cannot distinguish members: the one-shot extractAndWrapNodes(rule), and the deep-ness that nested rules inherit by walking rule.parent.

Approach

Per the spec in #15205 (comment), each list member must keep its own scoped nesting semantics:

.a[data-v-xxx],
.b[data-v-xxx] .c { color: red; }

.a > span[data-v-xxx],          /* not .a[data-v-xxx] > span */
.b[data-v-xxx] .c > span { color: blue; }

That needs the id in different positions inside the same body, which one shared body cannot express. So processRule gives each kind of member its own copy of the body, wrapped in &:where(<members of that kind>), and the existing code paths process each branch unchanged — no new scoping logic.

The selector list itself stays whole. Per css-nesting the specificity of & is the largest specificity in the parent selector list, so splitting the list into two rules would evaluate each branch against a smaller list and could change which declaration wins the cascade (see review). &:where() narrows what a branch matches while adding no specificity of its own, so nested rules keep exactly the weight they have today, plus the scope attribute they are supposed to get.

Input:

.a,
.b :deep(.c) { color: red; > span { color: blue; } }

Output:

.a,
.b[data-v-test] .c {
&:where(.a) {
&[data-v-test] { color: red; }
> span[data-v-test] { color: blue; }
}
&:where(.b[data-v-test] .c) {
  color: red;
> span { color: blue; }
}
}

:where() is Chrome 88 / Safari 14 / Firefox 78; the native nesting this code path already emits is Chrome 112 / Safari 16.5 / Firefox 117, so any browser that can parse the output supports :where().

The change is narrowly gated: it only fires when the body has a nested rule (including one below an at-rule such as @media; @keyframes are excluded), the list has more than one member, and the list mixes deep and plain members.

Because :where() is a forgiving selector list — it drops an argument it cannot parse instead of invalidating the rule — a rule is left exactly as it is on main whenever a member cannot be a :where() argument: members with pseudo elements, members written on & (inside a branch it would resolve against the mixed list itself), :global() members, and members that expand into several selectors such as :is(:deep(.foo), .bar) .baz. Each bail-out has a test asserting unchanged output.

Relation to #15206

#15206 resolves rule.__deep up-front. That fixes the reported ".a is unscoped" symptom, but the nested rule then yields .a[data-v-xxx] > span — the shape explicitly called out as wrong in the issue, and visible in that PR's own inline snapshot. Pre-computing the flag cannot get to .a > span[data-v-xxx], because the body is still shared.

Not covered

One related case stays as it is on main, and can be a follow-up:

  • :is(.a, :deep(.c)) with nested rules, which goes through splitSelectorForNestedDeep

Trade-off

The body is duplicated in the output for mixed lists — unavoidable if each branch needs its own id placement.

Tests

Added to compileStyle.spec.ts as exact inline snapshots: the reported case, the reversed order, a three-member list, a nested rule below @media, a case with an id member pinning the preserved nesting specificity, and controls asserting unchanged output for lists without nested rules, all-deep lists, and every bail-out above. vitest run --project unit --project unit-jsdom: 181 test files, 3675 tests passed, 5 skipped.

Cascade behavior verified in Chrome by mounting the compiled output against a competing .q.q.q.q.q.q.q.q > span rule — specificity (0,8,1): the nested rule keeps winning, the :deep() branch applies, and nothing leaks outside the component.

Summary by CodeRabbit

Bug Fixes

  • Improved scoped styling for :deep() selectors used alongside regular selectors in nested rules.
  • Preserved selector order, grouping, specificity, and formatting when separating deep and standard selectors.
  • Improved handling of nested at-rules, pseudo-elements, :global(), keyframes, and other complex selector combinations.
  • Maintained existing behavior when selector separation is unnecessary.

Tests

  • Added comprehensive regression coverage for mixed selector lists, nested rules, at-rules, and related selector scenarios.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0bb68bb0-0ad6-4989-8005-fe9a77add882

📥 Commits

Reviewing files that changed from the base of the PR and between bc56fd6 and e73ac25.

📒 Files selected for processing (2)
  • packages/compiler-sfc/__tests__/compileStyle.spec.ts
  • packages/compiler-sfc/src/style/pluginScoped.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/compiler-sfc/tests/compileStyle.spec.ts
  • packages/compiler-sfc/src/style/pluginScoped.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

Scoped style processing now splits mixed :deep() and plain selector lists when nested rules are present. The implementation preserves selector order and excludes unsupported selector forms. Regression tests cover nested rules, at-rules, specificity, pseudo-elements, global selectors, slotted selectors, and keyframes.

Changes

Scoped selector handling

Layer / File(s) Summary
Split mixed selectors and validate scoping
packages/compiler-sfc/src/style/pluginScoped.ts
processRule detects nested rules through non-keyframe at-rules and separates eligible mixed selectors into deep and plain branches. Helpers reject unsupported expansions, pseudo-elements, nesting selectors, :global(), and :slotted() selectors.
Cover mixed selector behavior
packages/compiler-sfc/__tests__/compileStyle.spec.ts
Tests cover selector ordering, grouping, nested at-rules, specificity, nested deep contexts, unchanged cases, unsupported selectors, :global(), :slotted(), and keyframes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to e73ac

This narrowly scoped compiler change corrects selector scoping for mixed :deep() nested rules without introducing an actionable merge-blocking risk; it is merge-ready after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant ScopedStyle
  participant processRule
  participant BranchRules
  participant compileStyleTests
  ScopedStyle->>processRule: process nested mixed selector list
  processRule->>BranchRules: create deep and plain branches
  BranchRules->>ScopedStyle: return rewritten scoped rules
  compileStyleTests->>ScopedStyle: validate ordering, specificity, and exclusions
Loading

Possibly related PRs

  • vuejs/core#15206: Both changes update pluginScoped.ts for mixed :deep() selector lists with nested rules.
  • vuejs/core#15233: Both changes handle mixed :deep() and plain selector lists in nested scoped rules.
  • vuejs/core#14725: Both changes update nested :deep() selector handling in pluginScoped.ts.

Suggested labels: scope: sfc, :hammer: p3-minor-bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the fix for scoped nested rules with mixed :deep() selector lists.
Linked Issues check ✅ Passed The changes address #15205 by scoping plain selectors correctly while preserving deep behavior for nested rules.
Out of Scope Changes check ✅ Passed The implementation and regression tests stay within the scope of #15205 and the stated CSS scoping fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/compiler-sfc/src/style/pluginScoped.ts`:
- Around line 105-106: Update the rule-check predicate in the scoped-style
processing flow to detect descendant rule nodes recursively through nested
at-rules, rather than only direct children, before deciding not to split.
Preserve the existing behavior for rules without any nested descendants, and add
a regression test covering a selector with :deep and a nested at-rule containing
a child combinator rule so the plain branch receives its scope attribute.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f300b76b-67cf-4448-8c8f-8e892fe6f76a

📥 Commits

Reviewing files that changed from the base of the PR and between a2b40db and daaa9ce.

📒 Files selected for processing (2)
  • packages/compiler-sfc/__tests__/compileStyle.spec.ts
  • packages/compiler-sfc/src/style/pluginScoped.ts

Comment thread packages/compiler-sfc/src/style/pluginScoped.ts Outdated

@edison1105 edison1105 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.

The branch-local scope placement for the reported case is now correct, but I do not think this can be merged as-is because splitting the parent selector list changes native CSS Nesting specificity.

Per the CSS Nesting specification, the specificity of & is the largest specificity among the selectors in the original parent selector list: https://drafts.csswg.org/css-nesting/#nest-selector

For example:

.a,
#b :deep(.c) {
  > span {}
}

The nested selector matching through .a originally derives its nesting specificity from the whole parent list, including the higher-specificity #b :deep(.c) member. After this PR splits the rule, the plain copy is evaluated under .a alone:

.a {
  > span[data-v-test] {}
}

That can change which declaration wins in the cascade even though the set of matched elements is now correct. This does not require an exotic :where() or :not() combination; a regular higher-specificity member is enough.

Please preserve the original parent-list specificity, or explicitly establish that diverging from native CSS Nesting specificity is an intended scoped-CSS semantic.

Non-blocking: the regression test currently uses separate toContain() checks. It would still pass if the deep copy incorrectly emitted > span[data-v-test]. An exact inline snapshot would verify both rule boundaries and that the nested selector in the deep branch remains unscoped.

…:deep()

Per the CSS nesting spec the specificity of `&` is the largest specificity in
the parent selector list, so splitting a mixed list into two rules evaluated
each branch against a smaller list and could change which declaration wins the
cascade. Keep the list intact and give each kind of member its own copy of the
body wrapped in `&:where(<members>)`, which narrows what a branch matches
without adding specificity of its own.

Members that cannot be expressed as a `:where()` argument - pseudo elements,
`&`, `:global()`, and members that expand into several selectors - are left
alone rather than silently reduced to matching nothing.
@ValentinYoushkevich

ValentinYoushkevich commented Aug 12, 2026

Copy link
Copy Markdown
Author

You're right, and rather than argue that the divergence is intended I changed the approach so there is no divergence to argue about.

What changed

The selector list is no longer split. It stays whole, and each kind of member gets its own copy of the body, wrapped in &:where(<members of that kind>):

/* input */
.a,
#b :deep(.c) {
  > span { color: blue; }
}

/* output */
.a,
#b[data-v-test] .c {
  &:where(.a) {
    > span[data-v-test] { color: blue; }
  }
  &:where(#b[data-v-test] .c) {
    > span { color: blue; }
  }
}

& still resolves against the whole list, so it still carries the specificity of #b[data-v-test] .c(1,2,0) — in both branches, and :where() narrows what a branch matches while contributing nothing of its own.

For the example in your review the nested rule goes from (1,2,1) on main to (1,3,1), and all of that delta is the [data-v-test] the rule is supposed to be getting. The :deep() branch is identical to what main emits today. Nothing in the implementation computes or compares specificity.

:where() is Chrome 88 / Safari 14 / Firefox 78. Native CSS nesting, which this code path already emits and depends on, is Chrome 112 / Safari 16.5 / Firefox 117 — so any browser that can parse the nested output has supported :where() for years.

Members that cannot be a :where() argument

:where() is a forgiving selector list: it drops an argument it cannot parse instead of invalidating the rule, which would silently turn a branch into "matches nothing". The rule is therefore left exactly as it is today whenever a member

  • contains a pseudo element (.a::before, .a:before, ::selection) — not valid inside :where();
  • is written on & — inside a branch & resolves against the mixed list itself, not against the rule the member was written against;
  • is :global() — neither scoped nor deep, so it belongs to neither branch;
  • expands into several selectors (:is(:deep(.foo), .bar) .baz) — cannot be attributed to a single branch.

Each of these has a test asserting the output is unchanged.

Tests

The regression tests are now exact inline snapshots, as you suggested — they pin both rule boundaries and the fact that the nested selector in the deep branch stays unscoped. There is also a snapshot for the #b case from your review, so the preserved nesting specificity is covered directly.

vitest run --project unit --project unit-jsdom: 181 test files, 3675 tests passed, 5 skipped.

I also checked the cascade itself in Chrome, mounting the three compiled outputs (main, the previous split, this one) side by side against a competing .q.q.q.q.q.q.q.q > span { color: red } rule — specificity (0,8,1), no id. main leaks outside the component, the split loses the nested rule to the competitor exactly as you described, and this output keeps the nested rule winning, applies the :deep() branch, and doesn't leak.

image

@ValentinYoushkevich ValentinYoushkevich changed the title fix(compiler-sfc): split mixed :deep() selector lists with nested rules fix(compiler-sfc): scope nested rules under mixed :deep() selector lists Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/compiler-sfc/src/style/pluginScoped.ts`:
- Around line 512-518: Update isGlobalSelector() to recursively inspect child
nodes of functional pseudo selectors, detecting nested :global or ::v-global
such as within :is(). Preserve the existing direct pseudo checks, and add a
regression test covering a mixed selector list like :is(:global(.b)) :deep(.c),
.a.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a637ce10-21df-4f52-880c-9a502fdb560a

📥 Commits

Reviewing files that changed from the base of the PR and between d35a8a7 and 57b7bcf.

📒 Files selected for processing (2)
  • packages/compiler-sfc/__tests__/compileStyle.spec.ts
  • packages/compiler-sfc/src/style/pluginScoped.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/compiler-sfc/tests/compileStyle.spec.ts

Comment thread packages/compiler-sfc/src/style/pluginScoped.ts
ValentinYoushkevich and others added 2 commits August 12, 2026 11:29
… splitting

`:global()` can sit inside `:is()` and friends, where the member-level check
did not see it. Such a member belongs to neither branch, so recurse the same
way isDeepSelector does and leave the rule alone.
…d :deep() lists

A :slotted() member gets its own '-s' attribute handling inside
rewriteSelector, which the branch wrapper of splitMixedDeepRuleBody
cannot reproduce - splitting the list would silently drop the scope
suffix. Detect :slotted() members the same way :global() members are
detected and fall back to the old behavior.

Ref: vuejs#15205
@haoku123

Copy link
Copy Markdown
Contributor

Hi @ValentinYoushkevich — nice work on this fix, the &:where() branch approach with preserved nesting specificity is really solid. I reviewed and locally verified the implementation (all 486 compiler-sfc tests pass on your branch).

While probing edge cases I found one gap: a :slotted() member in a mixed list loses its -s scope attribute, because the slotted handling in rewriteSelector cannot be reproduced through the branch wrapper. I opened a small PR against your branch with a fix (detect :slotted() members and fall back, mirroring the existing :global() handling): ValentinYoushkevich#1

Would you be open to incorporating it? Happy to adjust anything.

@ValentinYoushkevich

Copy link
Copy Markdown
Author

Good catch, thank you — confirmed. Before your fix the branch indeed dropped the -s attribute for a :slotted() member in a mixed list (.a, .b :deep(.c), :slotted(.e) { > span { … } } compiled the third member to a bare .e), which silently disables the selector.

Verified the patch locally: for every slotted variant I checked (:slotted() buried in :is(), with a descendant, the ::v-slotted alias, flat lists, lists without :deep()) the output is now byte-identical to main, so the split no longer changes any :slotted() behavior — the same conservative fallback already used for :global() members and &. Full compiler-sfc suite passes.

Merged into the branch — thanks for the PR!

@ValentinYoushkevich

Copy link
Copy Markdown
Author

@edison1105 Please take a look at my edits.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@vue/compiler-sfc: Partly unscoped styling with selector list & :deep & nesting

3 participants