Skip to content

fix(compiler-sfc): preserve selectors after global pseudo#14763

Closed
kirchoni wants to merge 1 commit intovuejs:mainfrom
kirchoni:codex/fix-global-selector-descendant
Closed

fix(compiler-sfc): preserve selectors after global pseudo#14763
kirchoni wants to merge 1 commit intovuejs:mainfrom
kirchoni:codex/fix-global-selector-descendant

Conversation

@kirchoni
Copy link
Copy Markdown

@kirchoni kirchoni commented Apr 27, 2026

fix #12404

👨: Hey, real human speaking here. Not sure what's your AI code policy, but I encountered this issue while trying to implement a css-vars based theme/switcher in a sli.dev project, and dedicated a couple of tokens to an agent to try and fix it.

🤖: When a scoped selector started with :global(...) and then continued with a descendant or combinator selector, the scoped CSS transform replaced the entire selector with the :global() inner selector. That dropped the trailing local selector and compiled :global(body) h1 to body.

This updates the :global()/::v-global rewrite to replace only the pseudo node, preserve trailing selector nodes for normal scoped processing, and keep standalone global selectors unscoped.

Added regression coverage for both :global(body) h1 and :global(html.dark) .foo.

Validation:

  • pnpm vitest packages/compiler-sfc/tests/compileStyle.spec.ts --run
  • pnpm check

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This PR fixes incorrect CSS selector compilation where :global(A) B was being compiled to just A instead of properly scoping only the non-global portion. The fix introduces logic to unwrap :global pseudo-selectors and prevent scope attribute injection for global portions while correctly scoping subsequent selectors.

Changes

Cohort / File(s) Summary
Test Coverage for Global Selectors
packages/compiler-sfc/__tests__/compileStyle.spec.ts
Adds test cases verifying that :global() with nontrivial global selectors (e.g., :global(body) and :global(html.dark)) correctly scope only the non-global portions with data-v-* attributes.
Global Selector Rewriting Logic
packages/compiler-sfc/src/style/pluginScoped.ts
Implements rewriteGlobalSelector pass to unwrap :global / ::v-global pseudo-selectors by removing preceding selector nodes and the pseudo itself, clearing leading whitespace, and exiting early to prevent scope attribute injection for the global portion.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

ready to merge, scope: sfc, :hammer: p3-minor-bug

Suggested reviewers

  • edison1105

Poem

🐰 A global selector, once quite the pest,
Now unwraps with grace, scoping the rest.
:global(A) B no longer plays tricks,
With trailing nodes fixed, the compiler sticks!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(compiler-sfc): preserve selectors after global pseudo' accurately describes the main change: fixing CSS selector preservation after :global() pseudo-elements.
Linked Issues check ✅ Passed The PR successfully addresses issue #12404 by implementing logic to preserve trailing selector nodes after :global() pseudo-elements, ensuring they receive proper scoping.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the :global() selector scope preservation issue; no unrelated modifications detected in the test file or plugin implementation.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
packages/compiler-sfc/__tests__/compileStyle.spec.ts (1)

243-252: Consider also covering the combinator case from issue #12404.

The linked issue explicitly mentions that descendant and combinator selectors (e.g. >) are affected. The two new assertions only exercise the descendant (whitespace) combinator. Adding one combinator case would lock in coverage of the full reported bug surface:

✅ Suggested extra assertion
     expect(compileScoped(`:global(html.dark) .foo { color: red; }`))
       .toMatchInlineSnapshot(`
       "html.dark .foo[data-v-test] { color: red;
       }"
     `)
+    expect(compileScoped(`:global(html) > body .foo { color: red; }`))
+      .toMatchInlineSnapshot()

(Run vitest with -u to populate the snapshot.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/compiler-sfc/__tests__/compileStyle.spec.ts` around lines 243 - 252,
Add a test case to cover the combinator selector scenario (e.g. the child
combinator `>`) in the same test file exercising the compileScoped helper; the
issue shows both descendant and combinator selectors can break, so add an
assertion calling compileScoped with a selector like `:global(body) > h1 {
color: red; }` (or similar) and verify the snapshot matches the expected
transformed output (run vitest -u to update snapshot). Locate the existing
assertions that call compileScoped in this spec (the two lines asserting
`:global(body) h1` and `:global(html.dark) .foo`) and add the new combinator
assertion nearby so the combinator case is covered.
packages/compiler-sfc/src/style/pluginScoped.ts (2)

346-372: Helper is clean; minor return-value semantics suggestion.

return !hasTrailingNodes couples two ideas (whether unwrapping happened, and whether the caller should skip). Consider returning hasTrailingNodes (or unwrapped + a separate flag) and letting the caller invert, or renaming to make the contract self-documenting, e.g.:

♻️ Optional clarity tweak
-function rewriteGlobalSelector(selector: selectorParser.Selector): boolean {
+/**
+ * Unwraps a top-level `:global(...)` / `::v-global(...)` pseudo in `selector`.
+ * Returns true when the selector consists solely of the global portion and
+ * therefore should skip the normal scoped-attribute injection.
+ */
+function rewriteGlobalSelector(selector: selectorParser.Selector): boolean {

Behavior is fine as-is; purely a readability nit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/compiler-sfc/src/style/pluginScoped.ts` around lines 346 - 372,
Summary: The return value of rewriteGlobalSelector is confusing because it
returns !hasTrailingNodes which couples unwrapping and caller-skip semantics;
change it to return hasTrailingNodes (or an explicit flag) and update callers
accordingly. Fix: modify rewriteGlobalSelector to return hasTrailingNodes (or an
object like {unwrapped: true, hasTrailingNodes}) instead of !hasTrailingNodes,
and then update all call sites that rely on the old boolean to invert the check
(or destructure the result if using an object); reference the function
rewriteGlobalSelector and the local variable hasTrailingNodes to locate and
change the logic and caller handling.

250-255: Legacy :global branch is now mostly dead code.

Since rewriteGlobalSelector runs before selector.each and unwraps the first top-level :global/::v-global (early-returning when there are no trailing nodes), this branch is only reachable if a second :global appears in the same compound/descendant chain (e.g. :global(.a) > :global(.b)), in which case it still does the old wholesale selector.replaceWith(n.nodes[0]) and silently drops .a > .

Consider either removing this branch (and letting rewriteGlobalSelector loop over all :global occurrences) or keeping it but documenting the two-:global limitation. Not a regression, but worth a follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/compiler-sfc/src/style/pluginScoped.ts` around lines 250 - 255, The
branch handling value === ':global' || value === '::v-global' in pluginScoped.ts
is legacy dead code that can drop preceding selector parts when multiple :global
occur; remove this branch (the selector.replaceWith(n.nodes[0]) early-return) so
rewriteGlobalSelector and the surrounding selector.each loop handle all
:global/::v-global occurrences consistently, then run/update tests for
rewriteGlobalSelector and selector.each to ensure multiple :global() in a
compound/descendant chain are unwrapped correctly and no parts are lost.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/compiler-sfc/__tests__/compileStyle.spec.ts`:
- Around line 243-252: Add a test case to cover the combinator selector scenario
(e.g. the child combinator `>`) in the same test file exercising the
compileScoped helper; the issue shows both descendant and combinator selectors
can break, so add an assertion calling compileScoped with a selector like
`:global(body) > h1 { color: red; }` (or similar) and verify the snapshot
matches the expected transformed output (run vitest -u to update snapshot).
Locate the existing assertions that call compileScoped in this spec (the two
lines asserting `:global(body) h1` and `:global(html.dark) .foo`) and add the
new combinator assertion nearby so the combinator case is covered.

In `@packages/compiler-sfc/src/style/pluginScoped.ts`:
- Around line 346-372: Summary: The return value of rewriteGlobalSelector is
confusing because it returns !hasTrailingNodes which couples unwrapping and
caller-skip semantics; change it to return hasTrailingNodes (or an explicit
flag) and update callers accordingly. Fix: modify rewriteGlobalSelector to
return hasTrailingNodes (or an object like {unwrapped: true, hasTrailingNodes})
instead of !hasTrailingNodes, and then update all call sites that rely on the
old boolean to invert the check (or destructure the result if using an object);
reference the function rewriteGlobalSelector and the local variable
hasTrailingNodes to locate and change the logic and caller handling.
- Around line 250-255: The branch handling value === ':global' || value ===
'::v-global' in pluginScoped.ts is legacy dead code that can drop preceding
selector parts when multiple :global occur; remove this branch (the
selector.replaceWith(n.nodes[0]) early-return) so rewriteGlobalSelector and the
surrounding selector.each loop handle all :global/::v-global occurrences
consistently, then run/update tests for rewriteGlobalSelector and selector.each
to ensure multiple :global() in a compound/descendant chain are unwrapped
correctly and no parts are lost.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 91800e4d-7c01-4714-b70e-207fff89d129

📥 Commits

Reviewing files that changed from the base of the PR and between 3310eea and 7da7a24.

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

@edison1105
Copy link
Copy Markdown
Member

duplicate of #12416

@edison1105 edison1105 closed this Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

:global(A) B incorrectly compiles to just A

2 participants