Repository: sveltejs/svelte (JavaScript)
Date: 2017-12-24 19:00:41+00:00
Task type: Feature

Issue:
Detect unused helpers, events, transitions
It occurred to me while doing #1050 that we could also detect unused helpers, events and transitions. I *think* that's all — we can't make any assumptions about computed properties and methods, since they could be being used outside the component.
Detect when dynamic components are in `components` and not `data`
[This](https://svelte.technology/repl?version=1.49.1&gist=d5a190af6c510194a969ebe458ac2b1f) is incorrect, but it's not immediately obvious why (it took me a while to figure out).

The component constructors need to be in `data`, *not* `components` — [like this](https://svelte.technology/repl?version=1.49.1&gist=b23156a9a1b969a057b2f479ac53c935). Is there a way to detect that mistake and warn on it?
Detect unused helpers, events, transitions
It occurred to me while doing #1050 that we could also detect unused helpers, events and transitions. I *think* that's all — we can't make any assumptions about computed properties and methods, since they could be being used outside the component.


Issue discussion:

Do they have to be in `data`?
I mean, can we not find a way around of leaving them in `components`.

I suppose this might mean components keeping track of all their children, but that might be better in the long run when HMR is introduced (maybe)

> can we not find a way around of leaving them in components

The short answer is 'no', it's not possible. The longer answer: if you had something like this...

```html
<VirtualList component={{Item}} height=500/>
```

...then inside the `<VirtualList>` component you'd have something like this:

```html
{{#each visibleItems as item}}
  <:Component {component} :item/>
{{/each}}
```

The parent component would have no knowledge that `component` was a special property and that `Item` should therefore come from `components` and not `data`, and the child component wouldn't have access to `Item` *other* than as data.

This is one of those things where something that *initially* seems like it would reduce confusion would end up creating a lot more. It's better if we just have one concept for this, and try and come up with ways to guide people towards the correct understanding — such as a dev-time warning that lets you know that `Item` was on Svelte's internal `expectedProperties` list and is present on `components` (where it is unused) but *not* on the initial `state` object created from `data`.


Solution patch:
diff --git a/src/generators/dom/index.ts b/src/generators/dom/index.ts
--- a/src/generators/dom/index.ts
+++ b/src/generators/dom/index.ts
@@ -212,10 +212,12 @@ export default function dom(
 		${generator.metaBindings}
 		${computations.length && `this._recompute({ ${Array.from(computationDeps).map(dep => `${dep}: 1`).join(', ')} }, this._state);`}
 		${options.dev &&
-			Array.from(generator.expectedProperties).map(
-				prop =>
-					`if (!('${prop}' in this._state)) console.warn("${debugName} was created without expected data property '${prop}'");`
-			)}
+			Array.from(generator.expectedProperties).map(prop => {
+				const message = generator.components.has(prop) ?
+					`${debugName} expected to find '${prop}' in \`data\`, but found it in \`components\` instead` :
+					`${debugName} was created without expected data property '${prop}'`;
+				return `if (!('${prop}' in this._state)) console.warn("${message}");`
+			})}
 		${generator.bindingGroups.length &&
 			`this._bindingGroups = [${Array(generator.bindingGroups.length).fill('[]').join(', ')}];`}
 
diff --git a/src/generators/server-side-rendering/index.ts b/src/generators/server-side-rendering/index.ts
--- a/src/generators/server-side-rendering/index.ts
+++ b/src/generators/server-side-rendering/index.ts
@@ -232,7 +232,7 @@ export default function ssr(
 		${
 			/__missingComponent/.test(generator.renderCode) && deindent`
 				var __missingComponent = {
-					render: () => ''
+					_render: () => ''
 				};
 			`
 		}
diff --git a/src/validate/html/validateElement.ts b/src/validate/html/validateElement.ts
--- a/src/validate/html/validateElement.ts
+++ b/src/validate/html/validateElement.ts
@@ -16,6 +16,10 @@ export default function validateElement(
 	const isComponent =
 		node.name === ':Self' || node.name === ':Component' || validator.components.has(node.name);
 
+	if (isComponent) {
+		validator.used.components.add(node.name);
+	}
+
 	if (!isComponent && /^[A-Z]/.test(node.name[0])) {
 		// TODO upgrade to validator.error in v2
 		validator.warn(`${node.name} component is not defined`, node.start);
@@ -134,12 +138,15 @@ export default function validateElement(
 				);
 			}
 		} else if (attribute.type === 'EventHandler') {
+			validator.used.events.add(attribute.name);
 			validateEventHandler(validator, attribute, refCallees);
 		} else if (attribute.type === 'Transition') {
 			if (isComponent) {
 				validator.error(`Transitions can only be applied to DOM elements, not components`, attribute.start);
 			}
 
+			validator.used.transitions.add(attribute.name);
+
 			const bidi = attribute.intro && attribute.outro;
 
 			if (hasTransition) {
diff --git a/src/validate/index.ts b/src/validate/index.ts
--- a/src/validate/index.ts
+++ b/src/validate/index.ts
@@ -35,6 +35,13 @@ export class Validator {
 	transitions: Map<string, Node>;
 	slots: Set<string>;
 
+	used: {
+		components: Set<string>;
+		helpers: Set<string>;
+		events: Set<string>;
+		transitions: Set<string>;
+	};
+
 	constructor(parsed: Parsed, source: string, options: CompileOptions) {
 		this.source = source;
 		this.filename = options.filename;
@@ -50,6 +57,13 @@ export class Validator {
 		this.helpers = new Map();
 		this.transitions = new Map();
 		this.slots = new Set();
+
+		this.used = {
+			components: new Set(),
+			helpers: new Set(),
+			events: new Set(),
+			transitions: new Set()
+		};
 	}
 
 	error(message: string, pos: number) {
@@ -114,6 +128,32 @@ export default function validate(
 		if (parsed.html) {
 			validateHtml(validator, parsed.html);
 		}
+
+		// need to do a second pass of the JS, now that we've analysed the markup
+		if (parsed.js && validator.defaultExport) {
+			const categories = {
+				components: 'component',
+				// TODO helpers require a bit more work — need to analyse all expressions
+				// helpers: 'helper',
+				events: 'event definition',
+				transitions: 'transition'
+			};
+
+			Object.keys(categories).forEach(category => {
+				const definitions = validator.defaultExport.declaration.properties.find(prop => prop.key.name === category);
+				if (definitions) {
+					definitions.value.properties.forEach(prop => {
+						const { name } = prop.key;
+						if (!validator.used[category].has(name)) {
+							validator.warn(
+								`The '${name}' ${categories[category]} is unused`,
+								prop.start
+							);
+						}
+					});
+				}
+			});
+		}
 	} catch (err) {
 		if (onerror) {
 			onerror(err);


Test patch:
diff --git a/test/runtime/samples/dev-warning-dynamic-components-misplaced/Bar.html b/test/runtime/samples/dev-warning-dynamic-components-misplaced/Bar.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/dev-warning-dynamic-components-misplaced/Bar.html
@@ -0,0 +1 @@
+Bar
\ No newline at end of file
diff --git a/test/runtime/samples/dev-warning-dynamic-components-misplaced/Foo.html b/test/runtime/samples/dev-warning-dynamic-components-misplaced/Foo.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/dev-warning-dynamic-components-misplaced/Foo.html
@@ -0,0 +1 @@
+Foo
\ No newline at end of file
diff --git a/test/runtime/samples/dev-warning-dynamic-components-misplaced/_config.js b/test/runtime/samples/dev-warning-dynamic-components-misplaced/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/dev-warning-dynamic-components-misplaced/_config.js
@@ -0,0 +1,14 @@
+export default {
+	dev: true,
+
+	data: {
+		x: true
+	},
+
+	html: '',
+
+	warnings: [
+		`<Main$> expected to find 'Foo' in \`data\`, but found it in \`components\` instead`,
+		`<Main$> expected to find 'Bar' in \`data\`, but found it in \`components\` instead`
+	]
+};
\ No newline at end of file
diff --git a/test/runtime/samples/dev-warning-dynamic-components-misplaced/main.html b/test/runtime/samples/dev-warning-dynamic-components-misplaced/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/dev-warning-dynamic-components-misplaced/main.html
@@ -0,0 +1,13 @@
+<:Component {x ? Foo : Bar}/>
+
+<script>
+	import Foo from './Foo.html';
+	import Bar from './Bar.html';
+
+	export default {
+		components: {
+			Foo,
+			Bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/properties-components-should-be-capitalised/input.html b/test/validator/samples/properties-components-should-be-capitalised/input.html
--- a/test/validator/samples/properties-components-should-be-capitalised/input.html
+++ b/test/validator/samples/properties-components-should-be-capitalised/input.html
@@ -1,4 +1,4 @@
-<div></div>
+<foo></foo>
 
 <script>
 	export default {
diff --git a/test/validator/samples/unused-components/input.html b/test/validator/samples/unused-components/input.html
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-components/input.html
@@ -0,0 +1,11 @@
+<script>
+	import Foo from './Foo.html';
+	import Bar from './Bar.html';
+
+	export default {
+		components: {
+			Foo,
+			Bar
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/unused-components/warnings.json b/test/validator/samples/unused-components/warnings.json
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-components/warnings.json
@@ -0,0 +1,18 @@
+[
+	{
+		"message": "The 'Foo' component is unused",
+		"loc": {
+			"line": 7,
+			"column": 3
+		},
+		"pos": 109
+	},
+	{
+		"message": "The 'Bar' component is unused",
+		"loc": {
+			"line": 8,
+			"column": 3
+		},
+		"pos": 117
+	}
+]
diff --git a/test/validator/samples/unused-event/input.html b/test/validator/samples/unused-event/input.html
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-event/input.html
@@ -0,0 +1,9 @@
+<script>
+	export default {
+		events: {
+			drag: (node, callback) => {
+				// implementation goes here
+			}
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/unused-event/warnings.json b/test/validator/samples/unused-event/warnings.json
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-event/warnings.json
@@ -0,0 +1,8 @@
+[{
+	"message": "The 'drag' event definition is unused",
+	"loc": {
+		"line": 4,
+		"column": 3
+	},
+	"pos": 42
+}]
diff --git a/test/validator/samples/unused-helper.skip/input.html b/test/validator/samples/unused-helper.skip/input.html
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-helper.skip/input.html
@@ -0,0 +1,7 @@
+<script>
+	export default {
+		helpers: {
+			uppercase: x => x.toUpperCase()
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/unused-helper.skip/warnings.json b/test/validator/samples/unused-helper.skip/warnings.json
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-helper.skip/warnings.json
@@ -0,0 +1,8 @@
+[{
+	"message": "The 'uppercase' helper is unused",
+	"loc": {
+		"line": 4,
+		"column": 3
+	},
+	"pos": 43
+}]
diff --git a/test/validator/samples/unused-transition/input.html b/test/validator/samples/unused-transition/input.html
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-transition/input.html
@@ -0,0 +1,9 @@
+<script>
+	export default {
+		transitions: {
+			spin: node => {
+				// implementation goes here
+			}
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/unused-transition/warnings.json b/test/validator/samples/unused-transition/warnings.json
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/unused-transition/warnings.json
@@ -0,0 +1,8 @@
+[{
+	"message": "The 'spin' transition is unused",
+	"loc": {
+		"line": 4,
+		"column": 3
+	},
+	"pos": 47
+}]