Repository: sveltejs/svelte (JavaScript)
Date: 2019-10-09 19:01:32+00:00
Task type: Bug Fix

Issue:
Dynamic class breaks CSS selector
**Describe the bug**
Some style rules aren't being applied when there is a dynamic class between the root node and target node of the selector.

**Logs**
N/A

**To Reproduce**
[https://svelte.dev/repl/c122185d662a4897a4cda657d07a3027?version=3.12.0](https://svelte.dev/repl/c122185d662a4897a4cda657d07a3027?version=3.12.0)

**Expected behavior**
I would expect all three lines of the REPL to have red text. Only the second and third do.

**Stacktraces**
N/A

**Information about your Svelte project:**
Firefox 69.0
Arch Linux
Svelte 3.12.0

**Severity**
It's annoying and very unexpected, but I can work around it.

**Additional context**
N/A

Dynamic class breaks CSS selector
**Describe the bug**
Some style rules aren't being applied when there is a dynamic class between the root node and target node of the selector.

**Logs**
N/A

**To Reproduce**
[https://svelte.dev/repl/c122185d662a4897a4cda657d07a3027?version=3.12.0](https://svelte.dev/repl/c122185d662a4897a4cda657d07a3027?version=3.12.0)

**Expected behavior**
I would expect all three lines of the REPL to have red text. Only the second and third do.

**Stacktraces**
N/A

**Information about your Svelte project:**
Firefox 69.0
Arch Linux
Svelte 3.12.0

**Severity**
It's annoying and very unexpected, but I can work around it.

**Additional context**
N/A



Issue discussion:
The problem here is that, the container div doesn't get the svelte-generated class name. The child's class can be any variable declared in a script tag too, and an {#if} tag also causes the same problem, so I guess it can be reproduced with any svelte-tag.

```
<div class="root">
	{#if true}
		<div class="{aNumber}">
			<div>a number</div>
		</div>
	{/if}
</div>
```

results in 
`<div class="root"><div class="24"><div>a number</div></div></div>`
Not a fix, but a "simple" workaround is to add an empty `.root{}` declaration to your style block. This forces Svelte to add a generated class name to the first `div`. 

Thankfully, the blank selector should be easily removed by a minifier while still maintaining the generated class. 

The problem here is that, the container div doesn't get the svelte-generated class name. The child's class can be any variable declared in a script tag too, and an {#if} tag also causes the same problem, so I guess it can be reproduced with any svelte-tag.

```
<div class="root">
	{#if true}
		<div class="{aNumber}">
			<div>a number</div>
		</div>
	{/if}
</div>
```

results in 
`<div class="root"><div class="24"><div>a number</div></div></div>`
Not a fix, but a "simple" workaround is to add an empty `.root{}` declaration to your style block. This forces Svelte to add a generated class name to the first `div`. 

Thankfully, the blank selector should be easily removed by a minifier while still maintaining the generated class. 


Solution patch:
diff --git a/src/compiler/compile/css/Selector.ts b/src/compiler/compile/css/Selector.ts
--- a/src/compiler/compile/css/Selector.ts
+++ b/src/compiler/compile/css/Selector.ts
@@ -4,6 +4,12 @@ import { gather_possible_values, UNKNOWN } from './gather_possible_values';
 import { CssNode } from './interfaces';
 import Component from '../Component';
 
+enum BlockAppliesToNode {
+	NotPossible,
+	Possible,
+	UnknownSelectorType
+}
+
 export default class Selector {
 	node: CssNode;
 	stylesheet: Stylesheet;
@@ -31,10 +37,10 @@ export default class Selector {
 	apply(node: CssNode, stack: CssNode[]) {
 		const to_encapsulate: CssNode[] = [];
 
-		apply_selector(this.stylesheet, this.local_blocks.slice(), node, stack.slice(), to_encapsulate);
+		apply_selector(this.local_blocks.slice(), node, stack.slice(), to_encapsulate);
 
 		if (to_encapsulate.length > 0) {
-			to_encapsulate.filter((_, i) => i === 0 || i === to_encapsulate.length - 1).forEach(({ node, block }) => {
+			to_encapsulate.forEach(({ node, block }) => {
 				this.stylesheet.nodes_with_css_class.add(node);
 				block.should_encapsulate = true;
 			});
@@ -126,7 +132,7 @@ export default class Selector {
 	}
 }
 
-function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: CssNode, stack: CssNode[], to_encapsulate: any[]): boolean {
+function apply_selector(blocks: Block[], node: CssNode, stack: CssNode[], to_encapsulate: any[]): boolean {
 	const block = blocks.pop();
 	if (!block) return false;
 
@@ -134,49 +140,30 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: CssNode,
 		return blocks.every(block => block.global);
 	}
 
-	let i = block.selectors.length;
-
-	while (i--) {
-		const selector = block.selectors[i];
-		const name = typeof selector.name === 'string' && selector.name.replace(/\\(.)/g, '$1');
-
-		if (selector.type === 'PseudoClassSelector' && name === 'global') {
-			// TODO shouldn't see this here... maybe we should enforce that :global(...)
-			// cannot be sandwiched between non-global selectors?
+	switch (block_might_apply_to_node(block, node)) {
+		case BlockAppliesToNode.NotPossible:
 			return false;
-		}
-
-		if (selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector') {
-			continue;
-		}
-
-		if (selector.type === 'ClassSelector') {
-			if (!attribute_matches(node, 'class', name, '~=', false) && !node.classes.some(c => c.name === name)) return false;
-		}
-
-		else if (selector.type === 'IdSelector') {
-			if (!attribute_matches(node, 'id', name, '=', false)) return false;
-		}
 
-		else if (selector.type === 'AttributeSelector') {
-			if (!attribute_matches(node, selector.name.name, selector.value && unquote(selector.value), selector.matcher, selector.flags)) return false;
-		}
-
-		else if (selector.type === 'TypeSelector') {
-			if (node.name.toLowerCase() !== name.toLowerCase() && name !== '*') return false;
-		}
-
-		else {
+		case BlockAppliesToNode.UnknownSelectorType:
 			// bail. TODO figure out what these could be
 			to_encapsulate.push({ node, block });
 			return true;
-		}
 	}
 
 	if (block.combinator) {
 		if (block.combinator.type === 'WhiteSpace') {
-			while (stack.length) {
-				if (apply_selector(stylesheet, blocks.slice(), stack.pop(), stack, to_encapsulate)) {
+			for (const ancestor_block of blocks) {
+				if (ancestor_block.global) {
+					continue;
+				}
+				
+				for (const stack_node of stack) {
+					if (block_might_apply_to_node(ancestor_block, stack_node) !== BlockAppliesToNode.NotPossible) {
+						to_encapsulate.push({ node: stack_node, block: ancestor_block });
+					}
+				}
+
+				if (to_encapsulate.length) {
 					to_encapsulate.push({ node, block });
 					return true;
 				}
@@ -189,7 +176,7 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: CssNode,
 
 			return false;
 		} else if (block.combinator.name === '>') {
-			if (apply_selector(stylesheet, blocks, stack.pop(), stack, to_encapsulate)) {
+			if (apply_selector(blocks, stack.pop(), stack, to_encapsulate)) {
 				to_encapsulate.push({ node, block });
 				return true;
 			}
@@ -206,6 +193,47 @@ function apply_selector(stylesheet: Stylesheet, blocks: Block[], node: CssNode,
 	return true;
 }
 
+function block_might_apply_to_node(block, node): BlockAppliesToNode {
+	let i = block.selectors.length;
+
+	while (i--) {
+		const selector = block.selectors[i];
+		const name = typeof selector.name === 'string' && selector.name.replace(/\\(.)/g, '$1');
+
+		if (selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector') {
+			continue;
+		}
+
+		if (selector.type === 'PseudoClassSelector' && name === 'global') {
+			// TODO shouldn't see this here... maybe we should enforce that :global(...)
+			// cannot be sandwiched between non-global selectors?
+			return BlockAppliesToNode.NotPossible;
+		}
+
+		if (selector.type === 'ClassSelector') {
+			if (!attribute_matches(node, 'class', name, '~=', false) && !node.classes.some(c => c.name === name)) return BlockAppliesToNode.NotPossible;
+		}
+
+		else if (selector.type === 'IdSelector') {
+			if (!attribute_matches(node, 'id', name, '=', false)) return BlockAppliesToNode.NotPossible;
+		}
+
+		else if (selector.type === 'AttributeSelector') {
+			if (!attribute_matches(node, selector.name.name, selector.value && unquote(selector.value), selector.matcher, selector.flags)) return BlockAppliesToNode.NotPossible;
+		}
+
+		else if (selector.type === 'TypeSelector') {
+			if (node.name.toLowerCase() !== name.toLowerCase() && name !== '*') return BlockAppliesToNode.NotPossible;
+		}
+
+		else {
+			return BlockAppliesToNode.UnknownSelectorType;
+		}
+	}
+
+	return BlockAppliesToNode.Possible;
+}
+
 function test_attribute(operator, expected_value, case_insensitive, value) {
 	if (case_insensitive) {
 		expected_value = expected_value.toLowerCase();


Test patch:
diff --git a/test/css/samples/omit-scoping-attribute-global-children/expected.css b/test/css/samples/omit-scoping-attribute-global-children/expected.css
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-global-children/expected.css
@@ -0,0 +1 @@
+.root.svelte-xyz p{color:red}
\ No newline at end of file
diff --git a/test/css/samples/omit-scoping-attribute-global-children/expected.html b/test/css/samples/omit-scoping-attribute-global-children/expected.html
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-global-children/expected.html
@@ -0,0 +1,4 @@
+<div class="root svelte-xyz">
+	<section class="whatever svelte-xyz">
+	</section>
+</div>
\ No newline at end of file
diff --git a/test/css/samples/omit-scoping-attribute-global-children/input.svelte b/test/css/samples/omit-scoping-attribute-global-children/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-global-children/input.svelte
@@ -0,0 +1,16 @@
+<script>
+	export let unknown1 = 'root';
+	export let unknown2 = 'whatever';
+</script>
+
+<style>
+	.root :global(p) {
+		color: red;
+	}
+</style>
+
+<div class={unknown1}>
+	<section class={unknown2}>
+		<!-- injected somehow -->
+	</section>
+</div>
diff --git a/test/css/samples/omit-scoping-attribute-global-descendants/expected.css b/test/css/samples/omit-scoping-attribute-global-descendants/expected.css
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-global-descendants/expected.css
@@ -0,0 +1 @@
+html body .root.svelte-xyz p.svelte-xyz{color:red}
\ No newline at end of file
diff --git a/test/css/samples/omit-scoping-attribute-global-descendants/expected.html b/test/css/samples/omit-scoping-attribute-global-descendants/expected.html
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-global-descendants/expected.html
@@ -0,0 +1,5 @@
+<div class="root svelte-xyz">
+	<section class="whatever svelte-xyz">
+		<p class="svelte-xyz">hello</p>
+	</section>
+</div>
\ No newline at end of file
diff --git a/test/css/samples/omit-scoping-attribute-global-descendants/input.svelte b/test/css/samples/omit-scoping-attribute-global-descendants/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-global-descendants/input.svelte
@@ -0,0 +1,16 @@
+<script>
+	export let unknown1 = 'root';
+	export let unknown2 = 'whatever';
+</script>
+
+<style>
+	:global(html) :global(body) .root p {
+		color: red;
+	}
+</style>
+
+<div class={unknown1}>
+	<section class={unknown2}>
+		<p>hello</p>
+	</section>
+</div>
diff --git a/test/css/samples/omit-scoping-attribute-multiple-descendants/expected.css b/test/css/samples/omit-scoping-attribute-multiple-descendants/expected.css
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-multiple-descendants/expected.css
@@ -0,0 +1 @@
+.root.svelte-xyz p.svelte-xyz{color:red}
\ No newline at end of file
diff --git a/test/css/samples/omit-scoping-attribute-multiple-descendants/expected.html b/test/css/samples/omit-scoping-attribute-multiple-descendants/expected.html
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-multiple-descendants/expected.html
@@ -0,0 +1,5 @@
+<div class="root svelte-xyz">
+	<section class="whatever svelte-xyz">
+		<p class="svelte-xyz">hello</p>
+	</section>
+</div>
\ No newline at end of file
diff --git a/test/css/samples/omit-scoping-attribute-multiple-descendants/input.svelte b/test/css/samples/omit-scoping-attribute-multiple-descendants/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/css/samples/omit-scoping-attribute-multiple-descendants/input.svelte
@@ -0,0 +1,16 @@
+<script>
+	export let unknown1 = 'root';
+	export let unknown2 = 'whatever';
+</script>
+
+<style>
+	.root p {
+		color: red;
+	}
+</style>
+
+<div class={unknown1}>
+	<section class={unknown2}>
+		<p>hello</p>
+	</section>
+</div>