Repository: sveltejs/svelte (JavaScript) — Bug Fix

Issue:
Descendant selector fails if outer element is not top-level
[REPL](https://svelte.technology/repl?version=1.29.2&gist=08f4e2525f02a822e9433c0bd288e1c5):

```html
<div>
  <p>
    <span>styled</span>
  </p>
</div>

<style>
  p span {
    color: red;
  }
</style>
```

The `<p>` element is not given the `svelte-xyz` attribute (but the `<div>` is, unnecessarily).


Discussion:

Resolving patch:
diff --git a/src/css/Selector.ts b/src/css/Selector.ts
--- a/src/css/Selector.ts
+++ b/src/css/Selector.ts
@@ -26,15 +26,16 @@ export default class Selector {
 	}
 
 	apply(node: Node, stack: Node[]) {
-		const applies = selectorAppliesTo(this.localBlocks.slice(), node, stack.slice());
+		const toEncapsulate: Node[] = [];
+		applySelector(this.localBlocks.slice(), node, stack.slice(), toEncapsulate);
 
-		if (applies) {
-			this.used = true;
+		if (toEncapsulate.length > 0) {
+			toEncapsulate.filter((_, i) => i === 0 || i === toEncapsulate.length - 1).forEach(({ node, block }) => {
+				node._needsCssAttribute = true;
+				block.shouldEncapsulate = true;
+			});
 
-			// add svelte-123xyz attribute to outermost and innermost
-			// elements — no need to add it to intermediate elements
-			node._needsCssAttribute = true;
-			if (stack[0] && this.node.children.find(isDescendantSelector)) stack[0]._needsCssAttribute = true;
+			this.used = true;
 		}
 	}
 
@@ -86,9 +87,9 @@ export default class Selector {
 				const first = selector.children[0];
 				const last = selector.children[selector.children.length - 1];
 				code.remove(selector.start, first.start).remove(last.end, selector.end);
-			} else if (i === 0 || i === this.blocks.length - 1) {
-				encapsulateBlock(block);
 			}
+
+			if (block.shouldEncapsulate) encapsulateBlock(block);
 		});
 	}
 
@@ -126,7 +127,7 @@ function isDescendantSelector(selector: Node) {
 	return selector.type === 'WhiteSpace' || selector.type === 'Combinator';
 }
 
-function selectorAppliesTo(blocks: Block[], node: Node, stack: Node[]): boolean {
+function applySelector(blocks: Block[], node: Node, stack: Node[], toEncapsulate: any[]): boolean {
 	const block = blocks.pop();
 	if (!block) return false;
 
@@ -169,6 +170,7 @@ function selectorAppliesTo(blocks: Block[], node: Node, stack: Node[]): boolean
 		else if (selector.type === 'RefSelector') {
 			if (node.attributes.some((attr: Node) => attr.type === 'Ref' && attr.name === selector.name)) {
 				node._cssRefAttribute = selector.name;
+				toEncapsulate.push({ node, block });
 				return true;
 			}
 			return;
@@ -176,6 +178,7 @@ function selectorAppliesTo(blocks: Block[], node: Node, stack: Node[]): boolean
 
 		else {
 			// bail. TODO figure out what these could be
+			toEncapsulate.push({ node, block });
 			return true;
 		}
 	}
@@ -183,20 +186,27 @@ function selectorAppliesTo(blocks: Block[], node: Node, stack: Node[]): boolean
 	if (block.combinator) {
 		if (block.combinator.type === 'WhiteSpace') {
 			while (stack.length) {
-				if (selectorAppliesTo(blocks.slice(), stack.pop(), stack)) {
+				if (applySelector(blocks.slice(), stack.pop(), stack, toEncapsulate)) {
+					toEncapsulate.push({ node, block });
 					return true;
 				}
 			}
 
 			return false;
 		} else if (block.combinator.name === '>') {
-			return selectorAppliesTo(blocks, stack.pop(), stack);
+			if (applySelector(blocks, stack.pop(), stack, toEncapsulate)) {
+				toEncapsulate.push({ node, block });
+				return true;
+			}
+			return false;
 		}
 
 		// TODO other combinators
+		toEncapsulate.push({ node, block });
 		return true;
 	}
 
+	toEncapsulate.push({ node, block });
 	return true;
 }
 
@@ -247,6 +257,7 @@ class Block {
 	selectors: Node[]
 	start: number;
 	end: number;
+	shouldEncapsulate: boolean;
 
 	constructor(combinator: Node) {
 		this.combinator = combinator;
@@ -255,6 +266,8 @@ class Block {
 
 		this.start = null;
 		this.end = null;
+
+		this.shouldEncapsulate = false;
 	}
 
 	add(selector: Node) {


Test patch:
diff --git a/test/css/index.js b/test/css/index.js
--- a/test/css/index.js
+++ b/test/css/index.js
@@ -1,66 +1,78 @@
-import assert from "assert";
-import * as fs from "fs";
-import { env, normalizeHtml, svelte } from "../helpers.js";
+import assert from 'assert';
+import * as fs from 'fs';
+import { env, normalizeHtml, svelte } from '../helpers.js';
 
 function tryRequire(file) {
 	try {
 		const mod = require(file);
 		return mod.default || mod;
 	} catch (err) {
-		if (err.code !== "MODULE_NOT_FOUND") throw err;
+		if (err.code !== 'MODULE_NOT_FOUND') throw err;
 		return null;
 	}
 }
 
 function normalizeWarning(warning) {
-	warning.frame = warning.frame.replace(/^\n/, '').replace(/^\t+/gm, '').replace(/\s+$/gm, '');
+	warning.frame = warning.frame
+		.replace(/^\n/, '')
+		.replace(/^\t+/gm, '')
+		.replace(/\s+$/gm, '');
 	delete warning.filename;
 	delete warning.toString;
 	return warning;
 }
 
-describe("css", () => {
-	fs.readdirSync("test/css/samples").forEach(dir => {
-		if (dir[0] === ".") return;
+describe('css', () => {
+	fs.readdirSync('test/css/samples').forEach(dir => {
+		if (dir[0] === '.') return;
 
 		// add .solo to a sample directory name to only run that test
 		const solo = /\.solo/.test(dir);
 		const skip = /\.skip/.test(dir);
 
 		if (solo && process.env.CI) {
-			throw new Error("Forgot to remove `solo: true` from test");
+			throw new Error('Forgot to remove `solo: true` from test');
 		}
 
 		(solo ? it.only : skip ? it.skip : it)(dir, () => {
 			const config = tryRequire(`./samples/${dir}/_config.js`) || {};
 			const input = fs
-				.readFileSync(`test/css/samples/${dir}/input.html`, "utf-8")
-				.replace(/\s+$/, "");
+				.readFileSync(`test/css/samples/${dir}/input.html`, 'utf-8')
+				.replace(/\s+$/, '');
 
 			const expectedWarnings = (config.warnings || []).map(normalizeWarning);
 			const domWarnings = [];
 			const ssrWarnings = [];
 
-			const dom = svelte.compile(input, Object.assign(config, {
-				format: 'iife',
-				name: 'SvelteComponent',
-				onwarn: warning => {
-					domWarnings.push(warning);
-				}
-			}));
-
-			const ssr = svelte.compile(input, Object.assign(config, {
-				format: 'iife',
-				generate: 'ssr',
-				name: 'SvelteComponent',
-				onwarn: warning => {
-					ssrWarnings.push(warning);
-				}
-			}));
+			const dom = svelte.compile(
+				input,
+				Object.assign(config, {
+					format: 'iife',
+					name: 'SvelteComponent',
+					onwarn: warning => {
+						domWarnings.push(warning);
+					}
+				})
+			);
+
+			const ssr = svelte.compile(
+				input,
+				Object.assign(config, {
+					format: 'iife',
+					generate: 'ssr',
+					name: 'SvelteComponent',
+					onwarn: warning => {
+						ssrWarnings.push(warning);
+					}
+				})
+			);
 
 			assert.equal(dom.css, ssr.css);
 
-			assert.deepEqual(domWarnings.map(normalizeWarning), ssrWarnings.map(normalizeWarning));
+			assert.deepEqual(
+				domWarnings.map(normalizeWarning),
+				ssrWarnings.map(normalizeWarning)
+			);
 			assert.deepEqual(domWarnings.map(normalizeWarning), expectedWarnings);
 
 			fs.writeFileSync(`test/css/samples/${dir}/_actual.css`, dom.css);
@@ -75,25 +87,32 @@ describe("css", () => {
 			if (expected.html !== null) {
 				const window = env();
 
-				const Component = eval(`(function () { ${dom.code}; return SvelteComponent; }())`);
-				const target = window.document.querySelector("main");
+				const Component = eval(
+					`(function () { ${dom.code}; return SvelteComponent; }())`
+				);
+				const target = window.document.querySelector('main');
 
 				new Component({ target, data: config.data });
 				const html = target.innerHTML;
 
-        // dom
-        assert.equal(
-          normalizeHtml(window, html.replace(/svelte-\d+/g, 'svelte-xyz')),
-          normalizeHtml(window, expected.html)
-        );
-
 				fs.writeFileSync(`test/css/samples/${dir}/_actual.html`, html);
 
+				// dom
+				assert.equal(
+					normalizeHtml(window, html.replace(/svelte-\d+/g, 'svelte-xyz')),
+					normalizeHtml(window, expected.html)
+				);
+
 				// ssr
-				const component = eval(`(function () { ${ssr.code}; return SvelteComponent; }())`);
+				const component = eval(
+					`(function () { ${ssr.code}; return SvelteComponent; }())`
+				);
 
 				assert.equal(
-					normalizeHtml(window, component.render(config.data).replace(/svelte-\d+/g, 'svelte-xyz')),
+					normalizeHtml(
+						window,
+						component.render(config.data).replace(/svelte-\d+/g, 'svelte-xyz')
+					),
 					normalizeHtml(window, expected.html)
 				);
 			}
@@ -104,7 +123,7 @@ describe("css", () => {
 function read(file) {
 	try {
 		return fs.readFileSync(file, 'utf-8');
-	} catch(err) {
+	} catch (err) {
 		return null;
 	}
-}
\ No newline at end of file
+}
diff --git a/test/css/samples/descendant-selector-non-top-level-outer/_config.js b/test/css/samples/descendant-selector-non-top-level-outer/_config.js
new file mode 100644
--- /dev/null
+++ b/test/css/samples/descendant-selector-non-top-level-outer/_config.js
@@ -0,0 +1,3 @@
+export default {
+	cascade: false
+};
\ No newline at end of file
diff --git a/test/css/samples/descendant-selector-non-top-level-outer/expected.css b/test/css/samples/descendant-selector-non-top-level-outer/expected.css
new file mode 100644
--- /dev/null
+++ b/test/css/samples/descendant-selector-non-top-level-outer/expected.css
@@ -0,0 +1 @@
+p[svelte-xyz] span[svelte-xyz]{color:red}
\ No newline at end of file
diff --git a/test/css/samples/descendant-selector-non-top-level-outer/expected.html b/test/css/samples/descendant-selector-non-top-level-outer/expected.html
new file mode 100644
--- /dev/null
+++ b/test/css/samples/descendant-selector-non-top-level-outer/expected.html
@@ -0,0 +1 @@
+<div><p svelte-xyz=''><span svelte-xyz=''>styled</span></p></div>
\ No newline at end of file
diff --git a/test/css/samples/descendant-selector-non-top-level-outer/input.html b/test/css/samples/descendant-selector-non-top-level-outer/input.html
new file mode 100644
--- /dev/null
+++ b/test/css/samples/descendant-selector-non-top-level-outer/input.html
@@ -0,0 +1,11 @@
+<div>
+	<p>
+		<span>styled</span>
+	</p>
+</div>
+
+<style>
+	p span {
+		color: red;
+	}
+</style>
\ No newline at end of file