diff --git a/src/compile/Component.ts b/src/compile/Component.ts index 560cc98961..919cfb1171 100644 --- a/src/compile/Component.ts +++ b/src/compile/Component.ts @@ -15,6 +15,7 @@ import { Node, Ast, CompileOptions, CustomElementOptions } from '../interfaces'; import error from '../utils/error'; import getCodeFrame from '../utils/getCodeFrame'; import flattenReference from '../utils/flattenReference'; +import addToSet from '../utils/addToSet'; // We need to tell estree-walker that it should always // look for an `else` block, otherwise it might get @@ -44,31 +45,32 @@ export default class Component { properties: Map; - imports: Node[]; + imports: Node[] = []; namespace: string; hasComponents: boolean; javascript: string; - declarations: string[]; - exports: Array<{ name: string, as: string }>; - event_handlers: Array<{ name: string, body: string }>; - props: string[]; + declarations: string[] = []; + writable_declarations: Set = new Set(); + initialised_declarations: Set = new Set(); + exports: Array<{ name: string, as: string }> = []; + event_handlers: Array<{ name: string, body: string }> = []; code: MagicString; - indirectDependencies: Map>; - expectedProperties: Set; - refs: Set; + indirectDependencies: Map> = new Map(); + expectedProperties: Set = new Set(); + refs: Set = new Set(); file: string; locate: (c: number) => { line: number, column: number }; stylesheet: Stylesheet; - userVars: Set; - templateVars: Map; - aliases: Map; - usedNames: Set; + userVars: Set = new Set(); + templateVars: Map = new Map(); + aliases: Map = new Map(); + usedNames: Set = new Set(); init_uses_self = false; locator: (search: number, startIndex?: number) => { @@ -89,38 +91,17 @@ export default class Component { this.source = source; this.options = options; - this.imports = []; - - this.declarations = []; - this.exports = []; - this.event_handlers = []; - - this.refs = new Set(); - - this.indirectDependencies = new Map(); - this.file = options.filename && ( typeof process !== 'undefined' ? options.filename.replace(process.cwd(), '').replace(/^[\/\\]/, '') : options.filename ); this.locate = getLocator(this.source); - // track which properties are needed, so we can provide useful info - // in dev mode - this.expectedProperties = new Set(); - this.code = new MagicString(source); // styles this.stylesheet = new Stylesheet(source, ast, options.filename, options.dev); this.stylesheet.validate(this); - // allow compiler to deconflict user's `import { flush } from 'whatever'` and - // Svelte's builtin `import { flush, ... } from 'svelte/internal.ts'`; - this.userVars = new Set(); - this.templateVars = new Map(); - this.aliases = new Map(); - this.usedNames = new Set(); - this.properties = new Map(); this.walkJs(); @@ -150,6 +131,7 @@ export default class Component { if (!this.ast.js) { this.declarations = Array.from(this.expectedProperties); + addToSet(this.writable_declarations, this.expectedProperties); this.exports = this.declarations.map(name => ({ name, @@ -390,6 +372,9 @@ export default class Component { this.declarations.push(name); }); + this.writable_declarations = scope.writable_declarations; + this.initialised_declarations = scope.initialised_declarations; + globals.forEach(name => { this.userVars.add(name); }); @@ -401,7 +386,7 @@ export default class Component { this.error(node, { code: `default-export`, message: `A component cannot have a default export` - }) + }); } if (node.type === 'ExportNamedDeclaration') { diff --git a/src/compile/render-dom/index.ts b/src/compile/render-dom/index.ts index 2011ca85f5..84f879cd8c 100644 --- a/src/compile/render-dom/index.ts +++ b/src/compile/render-dom/index.ts @@ -61,7 +61,8 @@ export default function dom( const globals = expectedProperties.filter(prop => globalWhitelist.has(prop)); if (component.customElement) { - const props = component.props || Array.from(component.expectedProperties); + // TODO use `export` to determine this + const props = Array.from(component.expectedProperties); builder.addBlock(deindent` class ${name} extends HTMLElement { @@ -118,8 +119,8 @@ export default function dom( ); } - builder.addBlock(deindent` - class ${name} extends ${superclass} { + const body = [ + deindent` $$init($$make_dirty) { ${component.init_uses_self && `const $$self = this;`} ${component.javascript || component.exports.map(x => `let ${x.name};`)} @@ -145,17 +146,59 @@ export default function dom( $$create_fragment(${component.alias('component')}, ctx) { ${block.getContents()} } + ` + ]; + + if (component.options.dev) { + // TODO check no uunexpected props were passed, as well as + // checking that expected ones were passed + const expected = component.exports + .map(x => x.name) + .filter(name => !component.initialised_declarations.has(name)); + + if (expected.length) { + const debug_name = `<${component.customElement ? component.tag : name}>`; + + body.push(deindent` + $$checkProps() { + const state = this.$$.get_state(); + ${expected.map(name => deindent` + + if (state.${name} === undefined) { + console.warn("${debug_name} was created without expected data property '${name}'"); + } + `)} + } + `); + } + } - ${component.exports.map(x => deindent` + component.exports.forEach(x => { + body.push(deindent` get ${x.as}() { return this.$$.get_state().${x.name}; } + `); - set ${x.as}(value) { - this.$set({ ${x.name}: value }); - @flush(); - } - `)} + if (component.writable_declarations.has(x.as)) { + body.push(deindent` + set ${x.as}(value) { + this.$set({ ${x.name}: value }); + @flush(); + } + `); + } else if (component.options.dev) { + body.push(deindent` + set ${x.as}(value) { + throw new Error("${x.as} is read-only"); + } + `); + } + }); + + builder.addBlock(deindent` + class ${name} extends ${superclass} { + ${body.join('\n\n')} } `); } diff --git a/src/internal/Component.js b/src/internal/Component.js index bc3f9a424a..96c6fb41bf 100644 --- a/src/internal/Component.js +++ b/src/internal/Component.js @@ -116,6 +116,7 @@ export class $$ComponentDev extends $$Component { } super(options); + this.$$checkProps(); } $destroy() { @@ -124,4 +125,8 @@ export class $$ComponentDev extends $$Component { console.warn(`Component was already destroyed`); }; } + + $$checkProps() { + // noop by default + } } \ No newline at end of file diff --git a/src/utils/annotateWithScopes.ts b/src/utils/annotateWithScopes.ts index 5b0a28efb5..5d316d0797 100644 --- a/src/utils/annotateWithScopes.ts +++ b/src/utils/annotateWithScopes.ts @@ -54,21 +54,28 @@ export function createScopes(expression: Node) { export class Scope { parent: Scope; block: boolean; - declarations: Set; + + declarations: Set = new Set(); + writable_declarations: Set = new Set(); + initialised_declarations: Set = new Set(); constructor(parent: Scope, block: boolean) { this.parent = parent; this.block = block; - this.declarations = new Set(); } addDeclaration(node: Node) { if (node.kind === 'var' && !this.block && this.parent) { this.parent.addDeclaration(node); } else if (node.type === 'VariableDeclaration') { + const writable = node.kind !== 'const'; + const initialised = !!node.init; + node.declarations.forEach((declarator: Node) => { extractNames(declarator.id).forEach(name => { this.declarations.add(name); + if (writable) this.writable_declarations.add(name); + if (initialised) this.initialised_declarations.add(name); }); }); } else { diff --git a/test/runtime/samples/dev-warning-missing-data/_config.js b/test/runtime/samples/dev-warning-missing-data/_config.js index aef14fd572..e45980bc1b 100644 --- a/test/runtime/samples/dev-warning-missing-data/_config.js +++ b/test/runtime/samples/dev-warning-missing-data/_config.js @@ -1,4 +1,6 @@ export default { + solo: 1, + compileOptions: { dev: true },