diff --git a/src/generators/dom/visitors/Component/Component.ts b/src/generators/dom/visitors/Component/Component.ts index 7145f5de36d7..a9fff4ee398a 100644 --- a/src/generators/dom/visitors/Component/Component.ts +++ b/src/generators/dom/visitors/Component/Component.ts @@ -212,7 +212,7 @@ export default function visitComponent( ${updates.join('\n')} - if ( Object.keys( ${name}_changes ).length ) ${name}.set( ${name}_changes ); + if ( Object.keys( ${name}_changes ).length ) ${name}._set( ${name}_changes ); `); } diff --git a/src/generators/dom/visitors/Component/EventHandler.ts b/src/generators/dom/visitors/Component/EventHandler.ts index 1010456253a9..0abef438213c 100644 --- a/src/generators/dom/visitors/Component/EventHandler.ts +++ b/src/generators/dom/visitors/Component/EventHandler.ts @@ -13,22 +13,25 @@ export default function visitEventHandler( local ) { // TODO verify that it's a valid callee (i.e. built-in or declared method) - generator.addSourcemapLocations(attribute.expression); - generator.code.prependRight( - attribute.expression.start, - `${block.alias('component')}.` - ); - const usedContexts: string[] = []; - attribute.expression.arguments.forEach((arg: Node) => { - const { contexts } = block.contextualise(arg, null, true); - contexts.forEach(context => { - if (!~usedContexts.indexOf(context)) usedContexts.push(context); - if (!~local.allUsedContexts.indexOf(context)) - local.allUsedContexts.push(context); + if (attribute.expression) { + generator.addSourcemapLocations(attribute.expression); + generator.code.prependRight( + attribute.expression.start, + `${block.alias('component')}.` + ); + + attribute.expression.arguments.forEach((arg: Node) => { + const { contexts } = block.contextualise(arg, null, true); + + contexts.forEach(context => { + if (!~usedContexts.indexOf(context)) usedContexts.push(context); + if (!~local.allUsedContexts.indexOf(context)) + local.allUsedContexts.push(context); + }); }); - }); + } // TODO hoist event handlers? can do `this.__component.method(...)` const declarations = usedContexts.map(name => { @@ -42,7 +45,9 @@ export default function visitEventHandler( const handlerBody = (declarations.length ? declarations.join('\n') + '\n\n' : '') + - `[✂${attribute.expression.start}-${attribute.expression.end}✂];`; + (attribute.expression ? + `[✂${attribute.expression.start}-${attribute.expression.end}✂];` : + `${block.alias('component')}.fire('${attribute.name}', event);`); local.create.addBlock(deindent` ${local.name}.on( '${attribute.name}', function ( event ) { diff --git a/src/generators/dom/visitors/Element/EventHandler.ts b/src/generators/dom/visitors/Element/EventHandler.ts index eb96c4e63674..56fdb42a8710 100644 --- a/src/generators/dom/visitors/Element/EventHandler.ts +++ b/src/generators/dom/visitors/Element/EventHandler.ts @@ -16,30 +16,33 @@ export default function visitEventHandler( const isCustomEvent = generator.events.has(name); const shouldHoist = !isCustomEvent && state.inEachBlock; - generator.addSourcemapLocations(attribute.expression); - - const flattened = flattenReference(attribute.expression.callee); - if (flattened.name !== 'event' && flattened.name !== 'this') { - // allow event.stopPropagation(), this.select() etc - // TODO verify that it's a valid callee (i.e. built-in or declared method) - generator.code.prependRight( - attribute.expression.start, - `${block.alias('component')}.` - ); - if (shouldHoist) state.usesComponent = true; // this feels a bit hacky but it works! - } - const context = shouldHoist ? null : state.parentNode; const usedContexts: string[] = []; - attribute.expression.arguments.forEach((arg: Node) => { - const { contexts } = block.contextualise(arg, context, true); - contexts.forEach(context => { - if (!~usedContexts.indexOf(context)) usedContexts.push(context); - if (!~state.allUsedContexts.indexOf(context)) - state.allUsedContexts.push(context); + if (attribute.expression) { + generator.addSourcemapLocations(attribute.expression); + + const flattened = flattenReference(attribute.expression.callee); + if (flattened.name !== 'event' && flattened.name !== 'this') { + // allow event.stopPropagation(), this.select() etc + // TODO verify that it's a valid callee (i.e. built-in or declared method) + generator.code.prependRight( + attribute.expression.start, + `${block.alias('component')}.` + ); + if (shouldHoist) state.usesComponent = true; // this feels a bit hacky but it works! + } + + attribute.expression.arguments.forEach((arg: Node) => { + const { contexts } = block.contextualise(arg, context, true); + + contexts.forEach(context => { + if (!~usedContexts.indexOf(context)) usedContexts.push(context); + if (!~state.allUsedContexts.indexOf(context)) + state.allUsedContexts.push(context); + }); }); - }); + } const _this = context || 'this'; const declarations = usedContexts.map(name => { @@ -66,7 +69,9 @@ export default function visitEventHandler( ${state.usesComponent && `var ${block.alias('component')} = ${_this}._svelte.component;`} ${declarations} - [✂${attribute.expression.start}-${attribute.expression.end}✂]; + ${attribute.expression ? + `[✂${attribute.expression.start}-${attribute.expression.end}✂];` : + `${block.alias('component')}.fire('${attribute.name}', event);`} `; if (isCustomEvent) { diff --git a/src/generators/dom/visitors/Element/Ref.ts b/src/generators/dom/visitors/Element/Ref.ts index 54af267ef641..e2db968989f8 100644 --- a/src/generators/dom/visitors/Element/Ref.ts +++ b/src/generators/dom/visitors/Element/Ref.ts @@ -17,7 +17,7 @@ export default function visitRef( `#component.refs.${name} = ${state.parentNode};` ); - block.builders.unmount.addLine(deindent` + block.builders.destroy.addLine(deindent` if ( #component.refs.${name} === ${state.parentNode} ) #component.refs.${name} = null; `); diff --git a/src/parse/index.ts b/src/parse/index.ts index 514e074fd4ae..b9748515e5a9 100644 --- a/src/parse/index.ts +++ b/src/parse/index.ts @@ -111,6 +111,8 @@ export class Parser { if (required) { this.error(`Expected ${str}`); } + + return false; } match(str: string) { diff --git a/src/parse/read/directives.ts b/src/parse/read/directives.ts index 6064a61fcbce..0b61ad635025 100644 --- a/src/parse/read/directives.ts +++ b/src/parse/read/directives.ts @@ -43,16 +43,21 @@ function readExpression(parser: Parser, start: number, quoteMark) { export function readEventHandlerDirective( parser: Parser, start: number, - name: string + name: string, + hasValue: boolean ) { - const quoteMark = parser.eat(`'`) ? `'` : parser.eat(`"`) ? `"` : null; + let expression; - const expressionStart = parser.index; + if (hasValue) { + const quoteMark = parser.eat(`'`) ? `'` : parser.eat(`"`) ? `"` : null; - const expression = readExpression(parser, expressionStart, quoteMark); + const expressionStart = parser.index; - if (expression.type !== 'CallExpression') { - parser.error(`Expected call expression`, expressionStart); + expression = readExpression(parser, expressionStart, quoteMark); + + if (expression.type !== 'CallExpression') { + parser.error(`Expected call expression`, expressionStart); + } } return { diff --git a/src/parse/state/tag.ts b/src/parse/state/tag.ts index 6fee187dd926..cfa056136578 100644 --- a/src/parse/state/tag.ts +++ b/src/parse/state/tag.ts @@ -256,8 +256,7 @@ function readAttribute(parser: Parser, uniqueNames) { parser.allowWhitespace(); if (/^on:/.test(name)) { - parser.eat('=', true); - return readEventHandlerDirective(parser, start, name.slice(3)); + return readEventHandlerDirective(parser, start, name.slice(3), parser.eat('=')); } if (/^bind:/.test(name)) { diff --git a/src/validate/html/index.ts b/src/validate/html/index.ts index b09cece0e402..ad653daa632c 100644 --- a/src/validate/html/index.ts +++ b/src/validate/html/index.ts @@ -1,6 +1,8 @@ import * as namespaces from '../../utils/namespaces'; import validateElement from './validateElement'; import validateWindow from './validateWindow'; +import fuzzymatch from '../utils/fuzzymatch' +import flattenReference from '../../utils/flattenReference'; import { Validator } from '../index'; import { Node } from '../../interfaces'; @@ -11,6 +13,9 @@ const meta = new Map([[':Window', validateWindow]]); export default function validateHtml(validator: Validator, html: Node) { let elementDepth = 0; + const refs = new Map(); + const refCallees: Node[] = []; + function visit(node: Node) { if (node.type === 'Element') { if ( @@ -25,12 +30,12 @@ export default function validateHtml(validator: Validator, html: Node) { } if (meta.has(node.name)) { - return meta.get(node.name)(validator, node); + return meta.get(node.name)(validator, node, refs, refCallees); } elementDepth += 1; - validateElement(validator, node); + validateElement(validator, node, refs, refCallees); } else if (node.type === 'EachBlock') { if (validator.helpers.has(node.context)) { let c = node.expression.end; @@ -61,4 +66,20 @@ export default function validateHtml(validator: Validator, html: Node) { } html.children.forEach(visit); + + refCallees.forEach(callee => { + const { parts } = flattenReference(callee); + const ref = parts[1]; + + if (refs.has(ref)) { + // TODO check method is valid, e.g. `audio.stop()` should be `audio.pause()` + } else { + const match = fuzzymatch(ref, Array.from(refs.keys())); + + let message = `'refs.${ref}' does not exist`; + if (match) message += ` (did you mean 'refs.${match}'?)`; + + validator.error(message, callee.start); + } + }); } diff --git a/src/validate/html/validateElement.ts b/src/validate/html/validateElement.ts index 69a50b6cff01..6658895c58f0 100644 --- a/src/validate/html/validateElement.ts +++ b/src/validate/html/validateElement.ts @@ -2,7 +2,7 @@ import validateEventHandler from './validateEventHandler'; import { Validator } from '../index'; import { Node } from '../../interfaces'; -export default function validateElement(validator: Validator, node: Node) { +export default function validateElement(validator: Validator, node: Node, refs: Map, refCallees: Node[]) { const isComponent = node.name === ':Self' || validator.components.has(node.name); @@ -16,6 +16,11 @@ export default function validateElement(validator: Validator, node: Node) { let hasTransition: boolean; node.attributes.forEach((attribute: Node) => { + if (attribute.type === 'Ref') { + if (!refs.has(attribute.name)) refs.set(attribute.name, []); + refs.get(attribute.name).push(node); + } + if (!isComponent && attribute.type === 'Binding') { const { name } = attribute; @@ -80,7 +85,7 @@ export default function validateElement(validator: Validator, node: Node) { ); } } else if (attribute.type === 'EventHandler') { - validateEventHandler(validator, attribute); + validateEventHandler(validator, attribute, refCallees); } else if (attribute.type === 'Transition') { const bidi = attribute.intro && attribute.outro; diff --git a/src/validate/html/validateEventHandler.ts b/src/validate/html/validateEventHandler.ts index 85fdad08e656..bc0bb23affe2 100644 --- a/src/validate/html/validateEventHandler.ts +++ b/src/validate/html/validateEventHandler.ts @@ -7,8 +7,11 @@ const validBuiltins = new Set(['set', 'fire', 'destroy']); export default function validateEventHandlerCallee( validator: Validator, - attribute: Node + attribute: Node, + refCallees: Node[] ) { + if (!attribute.expression) return; + const { callee, start, type } = attribute.expression; if (type !== 'CallExpression') { @@ -18,6 +21,12 @@ export default function validateEventHandlerCallee( const { name } = flattenReference(callee); if (name === 'this' || name === 'event') return; + + if (name === 'refs') { + refCallees.push(callee); + return; + } + if ( (callee.type === 'Identifier' && validBuiltins.has(callee.name)) || validator.methods.has(callee.name) diff --git a/src/validate/html/validateWindow.ts b/src/validate/html/validateWindow.ts index 7c78b7f37d11..e451bcc5344e 100644 --- a/src/validate/html/validateWindow.ts +++ b/src/validate/html/validateWindow.ts @@ -14,7 +14,7 @@ const validBindings = [ 'scrollY', ]; -export default function validateWindow(validator: Validator, node: Node) { +export default function validateWindow(validator: Validator, node: Node, refs: Map, refCallees: Node[]) { node.attributes.forEach((attribute: Node) => { if (attribute.type === 'Binding') { if (attribute.value.type !== 'Identifier') { @@ -50,7 +50,7 @@ export default function validateWindow(validator: Validator, node: Node) { } } } else if (attribute.type === 'EventHandler') { - validateEventHandler(validator, attribute); + validateEventHandler(validator, attribute, refCallees); } }); } diff --git a/test/runtime/samples/component-binding-blowback-c/Nested.html b/test/runtime/samples/component-binding-blowback-c/Nested.html new file mode 100644 index 000000000000..a1420a6916ad --- /dev/null +++ b/test/runtime/samples/component-binding-blowback-c/Nested.html @@ -0,0 +1,18 @@ +
  • + {{yield}} +
  • + + diff --git a/test/runtime/samples/component-binding-blowback-c/_config.js b/test/runtime/samples/component-binding-blowback-c/_config.js new file mode 100644 index 000000000000..78c3e915fd19 --- /dev/null +++ b/test/runtime/samples/component-binding-blowback-c/_config.js @@ -0,0 +1,33 @@ +export default { + 'skip-ssr': true, + + data: { + count: 3 + }, + + html: ` + +
      +
    1. id-2: value is two
    2. +
    3. id-1: value is one
    4. +
    5. id-0: value is zero
    6. +
    + `, + + test (assert, component, target, window) { + const input = target.querySelector('input'); + + input.value = 4; + input.dispatchEvent(new window.Event('input')); + + assert.htmlEqual(target.innerHTML, ` + +
      +
    1. id-3: value is three
    2. +
    3. id-2: value is two
    4. +
    5. id-1: value is one
    6. +
    7. id-0: value is zero
    8. +
    + `); + } +}; diff --git a/test/runtime/samples/component-binding-blowback-c/main.html b/test/runtime/samples/component-binding-blowback-c/main.html new file mode 100644 index 000000000000..3cf809552b16 --- /dev/null +++ b/test/runtime/samples/component-binding-blowback-c/main.html @@ -0,0 +1,34 @@ + + +
      + {{#each ids as object @id}} + + {{object.id}}: value is {{idToValue[object.id]}} + + {{/each}} +
    + + diff --git a/test/runtime/samples/event-handler-shorthand-component/Widget.html b/test/runtime/samples/event-handler-shorthand-component/Widget.html new file mode 100644 index 000000000000..47e1f95a2f93 --- /dev/null +++ b/test/runtime/samples/event-handler-shorthand-component/Widget.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/runtime/samples/event-handler-shorthand-component/_config.js b/test/runtime/samples/event-handler-shorthand-component/_config.js new file mode 100644 index 000000000000..e0e21f1400df --- /dev/null +++ b/test/runtime/samples/event-handler-shorthand-component/_config.js @@ -0,0 +1,18 @@ +export default { + html: ` + + `, + + test (assert, component, target, window) { + const button = target.querySelector('button'); + const event = new window.MouseEvent('click'); + + let answer; + component.on('foo', event => { + answer = event.answer; + }); + + button.dispatchEvent(event); + assert.equal(answer, 42); + } +}; diff --git a/test/runtime/samples/event-handler-shorthand-component/main.html b/test/runtime/samples/event-handler-shorthand-component/main.html new file mode 100644 index 000000000000..5eb9bbb3eb5c --- /dev/null +++ b/test/runtime/samples/event-handler-shorthand-component/main.html @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/event-handler-shorthand/_config.js b/test/runtime/samples/event-handler-shorthand/_config.js new file mode 100644 index 000000000000..ceded0bf0809 --- /dev/null +++ b/test/runtime/samples/event-handler-shorthand/_config.js @@ -0,0 +1,13 @@ +export default { + html: ` + + `, + + test (assert, component, target, window) { + const button = target.querySelector('button'); + const event = new window.MouseEvent('click'); + + button.dispatchEvent(event); + assert.ok(component.clicked); + } +}; diff --git a/test/runtime/samples/event-handler-shorthand/main.html b/test/runtime/samples/event-handler-shorthand/main.html new file mode 100644 index 000000000000..5b283cbb5998 --- /dev/null +++ b/test/runtime/samples/event-handler-shorthand/main.html @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/ondestroy-before-cleanup/Top.html b/test/runtime/samples/ondestroy-before-cleanup/Top.html new file mode 100644 index 000000000000..1e936b48fdb6 --- /dev/null +++ b/test/runtime/samples/ondestroy-before-cleanup/Top.html @@ -0,0 +1,9 @@ +
    + + diff --git a/test/runtime/samples/ondestroy-before-cleanup/_config.js b/test/runtime/samples/ondestroy-before-cleanup/_config.js new file mode 100644 index 000000000000..d83fbf17aedf --- /dev/null +++ b/test/runtime/samples/ondestroy-before-cleanup/_config.js @@ -0,0 +1,9 @@ +export default { + test(assert, component, target) { + const top = component.refs.top; + const div = target.querySelector('div'); + + component.set({ visible: false }); + assert.equal(top.refOnDestroy, div); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/ondestroy-before-cleanup/main.html b/test/runtime/samples/ondestroy-before-cleanup/main.html new file mode 100644 index 000000000000..c4b7ac879f0f --- /dev/null +++ b/test/runtime/samples/ondestroy-before-cleanup/main.html @@ -0,0 +1,18 @@ +{{#if visible}} + +{{/if}} + + \ No newline at end of file diff --git a/test/validator/index.js b/test/validator/index.js index 078299757303..9b4f4900b7b8 100644 --- a/test/validator/index.js +++ b/test/validator/index.js @@ -17,6 +17,10 @@ describe("validate", () => { const filename = `test/validator/samples/${dir}/input.html`; const input = fs.readFileSync(filename, "utf-8").replace(/\s+$/, ""); + const expectedWarnings = tryToLoadJson(`test/validator/samples/${dir}/warnings.json`) || []; + const expectedErrors = tryToLoadJson(`test/validator/samples/${dir}/errors.json`); + let error; + try { const warnings = []; @@ -30,20 +34,25 @@ describe("validate", () => { } }); - const expectedWarnings = - tryToLoadJson(`test/validator/samples/${dir}/warnings.json`) || []; - assert.deepEqual(warnings, expectedWarnings); - } catch (err) { - try { - const expected = require(`./samples/${dir}/errors.json`)[0]; + } catch (e) { + error = e; + } + + const expected = expectedErrors && expectedErrors[0]; - assert.equal(err.message, expected.message); - assert.deepEqual(err.loc, expected.loc); - assert.equal(err.pos, expected.pos); - } catch (err2) { - throw err2.code === "MODULE_NOT_FOUND" ? err : err2; + if (error || expected) { + if (error && !expected) { + throw error; } + + if (expected && !error) { + throw new Error(`Expected an error: ${expected.message}`); + } + + assert.equal(error.message, expected.message); + assert.deepEqual(error.loc, expected.loc); + assert.equal(error.pos, expected.pos); } }); }); diff --git a/test/validator/samples/event-handler-ref-invalid/errors.json b/test/validator/samples/event-handler-ref-invalid/errors.json new file mode 100644 index 000000000000..3ec1eb61b67c --- /dev/null +++ b/test/validator/samples/event-handler-ref-invalid/errors.json @@ -0,0 +1,8 @@ +[{ + "message": "'refs.inputx' does not exist (did you mean 'refs.input'?)", + "pos": 36, + "loc": { + "line": 2, + "column": 18 + } +}] \ No newline at end of file diff --git a/test/validator/samples/event-handler-ref-invalid/input.html b/test/validator/samples/event-handler-ref-invalid/input.html new file mode 100644 index 000000000000..f6cbe90c3a6b --- /dev/null +++ b/test/validator/samples/event-handler-ref-invalid/input.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/test/validator/samples/event-handler-ref/input.html b/test/validator/samples/event-handler-ref/input.html new file mode 100644 index 000000000000..e4fa0dec0feb --- /dev/null +++ b/test/validator/samples/event-handler-ref/input.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/test/validator/samples/event-handler-ref/warnings.json b/test/validator/samples/event-handler-ref/warnings.json new file mode 100644 index 000000000000..0637a088a01e --- /dev/null +++ b/test/validator/samples/event-handler-ref/warnings.json @@ -0,0 +1 @@ +[] \ No newline at end of file