diff --git a/.changeset/nice-numbers-remember.md b/.changeset/nice-numbers-remember.md
new file mode 100644
index 000000000000..810343aedf1c
--- /dev/null
+++ b/.changeset/nice-numbers-remember.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ensure explicit nesting selector is always applied
diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
index 4306d49d818f..4207a1650269 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js
@@ -77,38 +77,9 @@ const visitors = {
}
},
ComplexSelector(node, context) {
- const selectors = truncate(node);
+ const selectors = get_relative_selectors(node);
const inner = selectors[selectors.length - 1];
- if (node.metadata.rule?.metadata.parent_rule && selectors.length > 0) {
- let has_explicit_nesting_selector = false;
-
- // nesting could be inside pseudo classes like :is, :has or :where
- for (let selector of selectors) {
- walk(
- selector,
- {},
- {
- // @ts-ignore
- NestingSelector() {
- has_explicit_nesting_selector = true;
- }
- }
- );
- // if we found one we can break from the others
- if (has_explicit_nesting_selector) break;
- }
-
- if (!has_explicit_nesting_selector) {
- selectors[0] = {
- ...selectors[0],
- combinator: descendant_combinator
- };
-
- selectors.unshift(nesting_selector);
- }
- }
-
if (context.state.from_render_tag) {
// We're searching for a match that crosses a render tag boundary. That means we have to both traverse up
// the element tree (to see if we find an entry point) but also remove selectors from the end (assuming
@@ -156,6 +127,50 @@ const visitors = {
}
};
+/**
+ * Retrieves the relative selectors (minus the trailing globals) from a complex selector.
+ * Also searches them for any existing `&` selectors and adds one if none are found.
+ * This ensures we traverse up to the parent rule when the inner selectors match and we're
+ * trying to see if the parent rule also matches.
+ * @param {Compiler.Css.ComplexSelector} node
+ */
+function get_relative_selectors(node) {
+ const selectors = truncate(node);
+
+ if (node.metadata.rule?.metadata.parent_rule && selectors.length > 0) {
+ let has_explicit_nesting_selector = false;
+
+ // nesting could be inside pseudo classes like :is, :has or :where
+ for (let selector of selectors) {
+ walk(
+ selector,
+ {},
+ {
+ // @ts-ignore
+ NestingSelector() {
+ has_explicit_nesting_selector = true;
+ }
+ }
+ );
+ // if we found one we can break from the others
+ if (has_explicit_nesting_selector) break;
+ }
+
+ if (!has_explicit_nesting_selector) {
+ if (selectors[0].combinator === null) {
+ selectors[0] = {
+ ...selectors[0],
+ combinator: descendant_combinator
+ };
+ }
+
+ selectors.unshift(nesting_selector);
+ }
+ }
+
+ return selectors;
+}
+
/**
* Discard trailing `:global(...)` selectors without a `:has/is/where/not(...)` modifier, these are unused for scoping purposes
* @param {Compiler.Css.ComplexSelector} node
@@ -641,7 +656,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
const parent = /** @type {Compiler.Css.Rule} */ (rule.metadata.parent_rule);
for (const complex_selector of parent.prelude.children) {
- if (apply_selector(truncate(complex_selector), parent, element, state)) {
+ if (apply_selector(get_relative_selectors(complex_selector), parent, element, state)) {
complex_selector.metadata.used = true;
matched = true;
}
diff --git a/packages/svelte/tests/css/samples/nested-in-pseudo/_config.js b/packages/svelte/tests/css/samples/nested-in-pseudo/_config.js
deleted file mode 100644
index 292c6c49ac9d..000000000000
--- a/packages/svelte/tests/css/samples/nested-in-pseudo/_config.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { test } from '../../test';
-
-export default test({
- warnings: []
-});
diff --git a/packages/svelte/tests/css/samples/nested-in-pseudo/expected.css b/packages/svelte/tests/css/samples/nested-in-pseudo/expected.css
deleted file mode 100644
index 3f365a898884..000000000000
--- a/packages/svelte/tests/css/samples/nested-in-pseudo/expected.css
+++ /dev/null
@@ -1,6 +0,0 @@
-
-nav.svelte-xyz{
- header:where(.svelte-xyz):has(&){
- color: red;
- }
-}
diff --git a/packages/svelte/tests/css/samples/nested-in-pseudo/expected.html b/packages/svelte/tests/css/samples/nested-in-pseudo/expected.html
deleted file mode 100644
index 44a5d4a52dd6..000000000000
--- a/packages/svelte/tests/css/samples/nested-in-pseudo/expected.html
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/svelte/tests/css/samples/nested-in-pseudo/input.svelte b/packages/svelte/tests/css/samples/nested-in-pseudo/input.svelte
deleted file mode 100644
index 8a6758f60f3f..000000000000
--- a/packages/svelte/tests/css/samples/nested-in-pseudo/input.svelte
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/packages/svelte/tests/css/samples/nesting-selectors/_config.js b/packages/svelte/tests/css/samples/nesting-selectors/_config.js
new file mode 100644
index 000000000000..8960eff1b684
--- /dev/null
+++ b/packages/svelte/tests/css/samples/nesting-selectors/_config.js
@@ -0,0 +1,48 @@
+import { test } from '../../test';
+
+export default test({
+ warnings: [
+ {
+ code: 'css_unused_selector',
+ message: 'Unused CSS selector ".unused:has(&)"',
+ start: {
+ line: 10,
+ column: 2,
+ character: 105
+ },
+ end: {
+ line: 10,
+ column: 16,
+ character: 119
+ }
+ },
+ {
+ code: 'css_unused_selector',
+ message: 'Unused CSS selector "&.unused"',
+ start: {
+ line: 23,
+ column: 3,
+ character: 223
+ },
+ end: {
+ line: 23,
+ column: 11,
+ character: 231
+ }
+ },
+ {
+ code: 'css_unused_selector',
+ message: 'Unused CSS selector "&.unused"',
+ start: {
+ line: 37,
+ column: 3,
+ character: 344
+ },
+ end: {
+ line: 37,
+ column: 11,
+ character: 352
+ }
+ }
+ ]
+});
diff --git a/packages/svelte/tests/css/samples/nesting-selectors/expected.css b/packages/svelte/tests/css/samples/nesting-selectors/expected.css
new file mode 100644
index 000000000000..1b606b10cae1
--- /dev/null
+++ b/packages/svelte/tests/css/samples/nesting-selectors/expected.css
@@ -0,0 +1,37 @@
+
+ nav.svelte-xyz {
+ header:where(.svelte-xyz):has(&){
+ color: green;
+ }
+ /* (unused) .unused:has(&){
+ color: red;
+ }*/
+ }
+
+ header.svelte-xyz {
+ > nav:where(.svelte-xyz) {
+ color: green;
+
+ &.active {
+ color: green;
+ }
+
+ /* (unused) &.unused {
+ color: red;
+ }*/
+ }
+ }
+
+ header.svelte-xyz {
+ & > nav:where(.svelte-xyz) {
+ color: green;
+
+ &.active {
+ color: green;
+ }
+
+ /* (unused) &.unused {
+ color: red;
+ }*/
+ }
+ }
diff --git a/packages/svelte/tests/css/samples/nesting-selectors/expected.html b/packages/svelte/tests/css/samples/nesting-selectors/expected.html
new file mode 100644
index 000000000000..59e17c2d028a
--- /dev/null
+++ b/packages/svelte/tests/css/samples/nesting-selectors/expected.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/css/samples/nesting-selectors/input.svelte b/packages/svelte/tests/css/samples/nesting-selectors/input.svelte
new file mode 100644
index 000000000000..98881237c019
--- /dev/null
+++ b/packages/svelte/tests/css/samples/nesting-selectors/input.svelte
@@ -0,0 +1,42 @@
+
+
+
\ No newline at end of file