From 5866a99b9a8b81a613675c67964e06b16108c193 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Mar 2017 17:53:55 -0400 Subject: [PATCH] treat binding values as expressions --- src/generators/dom/visitors/Component.js | 1 + .../attributes/addComponentAttributes.js | 4 +- .../attributes/addComponentBinding.js | 98 +++++++++ .../attributes/addElementAttributes.js | 4 +- .../visitors/attributes/addElementBinding.js | 144 +++++++++++++ .../dom/visitors/attributes/binding/index.js | 190 ------------------ src/generators/server-side-rendering/index.js | 7 +- .../visitors/Component.js | 6 +- src/parse/read/directives.js | 32 ++- src/utils/flattenReference.js | 2 +- .../samples/binding-input-checkbox/_config.js | 2 + .../samples/binding-shorthand/output.json | 7 +- test/parser/samples/binding/output.json | 7 +- 13 files changed, 300 insertions(+), 204 deletions(-) create mode 100644 src/generators/dom/visitors/attributes/addComponentBinding.js create mode 100644 src/generators/dom/visitors/attributes/addElementBinding.js delete mode 100644 src/generators/dom/visitors/attributes/binding/index.js diff --git a/src/generators/dom/visitors/Component.js b/src/generators/dom/visitors/Component.js index 990b947f7c..a1fea80663 100644 --- a/src/generators/dom/visitors/Component.js +++ b/src/generators/dom/visitors/Component.js @@ -1,5 +1,6 @@ import deindent from '../../../utils/deindent.js'; import CodeBuilder from '../../../utils/CodeBuilder.js'; +import flattenReference from '../../../utils/flattenReference.js'; import addComponentAttributes from './attributes/addComponentAttributes.js'; function capDown ( name ) { diff --git a/src/generators/dom/visitors/attributes/addComponentAttributes.js b/src/generators/dom/visitors/attributes/addComponentAttributes.js index 888b3ca3d5..5175a644c6 100644 --- a/src/generators/dom/visitors/attributes/addComponentAttributes.js +++ b/src/generators/dom/visitors/attributes/addComponentAttributes.js @@ -1,4 +1,4 @@ -import createBinding from './binding/index.js'; +import addComponentBinding from './addComponentBinding.js'; import deindent from '../../../../utils/deindent.js'; export default function addComponentAttributes ( generator, node, local ) { @@ -112,7 +112,7 @@ export default function addComponentAttributes ( generator, node, local ) { } else if ( attribute.type === 'Binding' ) { - createBinding( generator, node, attribute, generator.current, local ); + addComponentBinding( generator, node, attribute, generator.current, local ); } else if ( attribute.type === 'Ref' ) { diff --git a/src/generators/dom/visitors/attributes/addComponentBinding.js b/src/generators/dom/visitors/attributes/addComponentBinding.js new file mode 100644 index 0000000000..2fa5dd4e32 --- /dev/null +++ b/src/generators/dom/visitors/attributes/addComponentBinding.js @@ -0,0 +1,98 @@ +import deindent from '../../../../utils/deindent.js'; +import isReference from '../../../../utils/isReference.js'; +import flattenReference from '../../../../utils/flattenReference.js'; + +export default function createBinding ( generator, node, attribute, current, local ) { + const { name, parts, keypath } = flattenReference( attribute.value ); + + const contextual = name in current.contexts; + + if ( contextual && !~local.allUsedContexts.indexOf( name ) ) { + local.allUsedContexts.push( name ); + } + + let obj; + let prop; + let value; + + if ( contextual ) { + obj = current.listNames[ name ]; + prop = current.indexNames[ name ]; + value = keypath; + } else { + prop = `'${parts.slice( -1 )}'`; + obj = parts.length > 1 ? `root.${parts.slice( 0, -1 ).join( '.' )}` : `root`; + value = `root.${keypath}`; + } + + local.bindings.push({ name: attribute.name, value, obj, prop }); + + let setter; + + if ( contextual ) { + // find the top-level property that this is a child of + let fragment = current; + let prop = name; + + do { + if ( fragment.expression && fragment.context === prop ) { + if ( !isReference( fragment.expression ) ) { + // TODO this should happen in prior validation step + throw new Error( `${prop} is read-only, it cannot be bound` ); + } + + prop = flattenReference( fragment.expression ).name; + } + } while ( fragment = fragment.parent ); + + generator.expectedProperties[ prop ] = true; + + const listName = current.listNames[ name ]; + const indexName = current.indexNames[ name ]; + + const context = local.isComponent ? `_context` : `__svelte`; + + setter = deindent` + var list = this.${context}.${listName}; + var index = this.${context}.${indexName}; + list[index]${parts.slice( 1 ).map( part => `.${part}` ).join( '' )} = ${value}; + + component._set({ ${prop}: component.get( '${prop}' ) }); + `; + } else { + if ( parts.length > 1 ) { + setter = deindent` + var ${name} = component.get( '${name}' ); + ${name}.${parts.slice( 1 ).join( '.' )} = ${value}; + component._set({ ${name}: ${name} }); + `; + } else { + setter = `component._set({ ${keypath}: value });`; + } + + generator.expectedProperties[ name ] = true; + } + + generator.hasComplexBindings = true; + + local.init.addBlock( deindent` + var ${local.name}_updating = false; + + component._bindings.push( function () { + if ( ${local.name}._torndown ) return; + ${local.name}.observe( '${attribute.name}', function ( value ) { + ${local.name}_updating = true; + ${setter} + ${local.name}_updating = false; + }); + }); + ` ); + + const dependencies = name in current.contexts ? current.contextDependencies[ name ] : [ name ]; + + local.update.addBlock( deindent` + if ( !${local.name}_updating && ${dependencies.map( dependency => `'${dependency}' in changed` ).join( '||' )} ) { + ${local.name}._set({ ${attribute.name}: ${contextual ? keypath : `root.${keypath}`} }); + } + ` ); +} diff --git a/src/generators/dom/visitors/attributes/addElementAttributes.js b/src/generators/dom/visitors/attributes/addElementAttributes.js index 6c85f6a73c..57605a9a1e 100644 --- a/src/generators/dom/visitors/attributes/addElementAttributes.js +++ b/src/generators/dom/visitors/attributes/addElementAttributes.js @@ -1,5 +1,5 @@ import attributeLookup from './lookup.js'; -import createBinding from './binding/index.js'; +import addElementBinding from './addElementBinding'; import deindent from '../../../../utils/deindent.js'; import flattenReference from '../../../../utils/flattenReference.js'; @@ -204,7 +204,7 @@ export default function addElementAttributes ( generator, node, local ) { } else if ( attribute.type === 'Binding' ) { - createBinding( generator, node, attribute, generator.current, local ); + addElementBinding( generator, node, attribute, generator.current, local ); } else if ( attribute.type === 'Ref' ) { diff --git a/src/generators/dom/visitors/attributes/addElementBinding.js b/src/generators/dom/visitors/attributes/addElementBinding.js new file mode 100644 index 0000000000..ad1aac8214 --- /dev/null +++ b/src/generators/dom/visitors/attributes/addElementBinding.js @@ -0,0 +1,144 @@ +import deindent from '../../../../utils/deindent.js'; +import isReference from '../../../../utils/isReference.js'; +import flattenReference from '../../../../utils/flattenReference.js'; + +export default function createBinding ( generator, node, attribute, current, local ) { + const { name, parts, keypath } = flattenReference( attribute.value ); + + const contextual = name in current.contexts; + + if ( contextual && !~local.allUsedContexts.indexOf( name ) ) { + local.allUsedContexts.push( name ); + } + + const handler = current.getUniqueName( `${local.name}ChangeHandler` ); + let setter; + + let eventName = 'change'; + if ( node.name === 'input' ) { + const typeAttribute = node.attributes.find( attr => attr.type === 'Attribute' && attr.name === 'type' ); + const type = typeAttribute ? typeAttribute.value[0].data : 'text'; // TODO in validation, should throw if type attribute is not static + + if ( type !== 'checkbox' && type !== 'radio' ) { + eventName = 'input'; + } + } + + else if ( node.name === 'textarea' ) { + eventName = 'input'; + } + + const isMultipleSelect = node.name === 'select' && node.attributes.find( attr => attr.name.toLowerCase() === 'multiple' ); // TODO ensure that this is a static attribute + let value; + + if ( node.name === 'select' ) { + if ( isMultipleSelect ) { + value = `[].map.call( ${local.name}.selectedOptions, function ( option ) { return option.__value; })`; + } else { + value = 'selectedOption && selectedOption.__value'; + } + } else { + value = `${local.name}.${attribute.name}`; + } + + if ( contextual ) { + // find the top-level property that this is a child of + let fragment = current; + let prop = name; + + do { + if ( fragment.expression && fragment.context === prop ) { + if ( !isReference( fragment.expression ) ) { + // TODO this should happen in prior validation step + throw new Error( `${prop} is read-only, it cannot be bound` ); + } + + prop = flattenReference( fragment.expression ).name; + } + } while ( fragment = fragment.parent ); + + generator.expectedProperties[ prop ] = true; + + const listName = current.listNames[ name ]; + const indexName = current.indexNames[ name ]; + + const context = local.isComponent ? `_context` : `__svelte`; + + setter = deindent` + var list = this.${context}.${listName}; + var index = this.${context}.${indexName}; + list[index]${parts.slice( 1 ).map( part => `.${part}` ).join( '' )} = ${value}; + + component._set({ ${prop}: component.get( '${prop}' ) }); + `; + } else { + if ( parts.length > 1 ) { + setter = deindent` + var ${name} = component.get( '${name}' ); + ${name}.${parts.slice( 1 ).join( '.' )} = ${value}; + component._set({ ${name}: ${name} }); + `; + } else { + setter = `component._set({ ${keypath}: ${value} });`; + } + + generator.expectedProperties[ name ] = true; + } + + // special case + if ( node.name === 'select' && !isMultipleSelect ) { + setter = `var selectedOption = ${local.name}.selectedOptions[0] || ${local.name}.options[0];\n` + setter; + } + + let updateElement; + + if ( node.name === 'select' ) { + const value = generator.current.getUniqueName( 'value' ); + const i = generator.current.getUniqueName( 'i' ); + const option = generator.current.getUniqueName( 'option' ); + + const ifStatement = isMultipleSelect ? + deindent` + ${option}.selected = ~${value}.indexOf( ${option}.__value );` : + deindent` + if ( ${option}.__value === ${value} ) { + ${option}.selected = true; + break; + }`; + + updateElement = deindent` + var ${value} = ${contextual ? keypath : `root.${keypath}`}; + for ( var ${i} = 0; ${i} < ${local.name}.options.length; ${i} += 1 ) { + var ${option} = ${local.name}.options[${i}]; + + ${ifStatement} + } + `; + } else { + updateElement = `${local.name}.${attribute.name} = ${contextual ? keypath : `root.${keypath}`};`; + } + + local.init.addBlock( deindent` + var ${local.name}_updating = false; + + function ${handler} () { + ${local.name}_updating = true; + ${setter} + ${local.name}_updating = false; + } + + ${generator.helper( 'addEventListener' )}( ${local.name}, '${eventName}', ${handler} ); + ` ); + + node.initialUpdate = updateElement; + + local.update.addLine( deindent` + if ( !${local.name}_updating ) { + ${updateElement} + } + ` ); + + generator.current.builders.teardown.addLine( deindent` + ${generator.helper( 'removeEventListener' )}( ${local.name}, '${eventName}', ${handler} ); + ` ); +} diff --git a/src/generators/dom/visitors/attributes/binding/index.js b/src/generators/dom/visitors/attributes/binding/index.js deleted file mode 100644 index 2425aaaad7..0000000000 --- a/src/generators/dom/visitors/attributes/binding/index.js +++ /dev/null @@ -1,190 +0,0 @@ -import deindent from '../../../../../utils/deindent.js'; -import isReference from '../../../../../utils/isReference.js'; -import flattenReference from '../../../../../utils/flattenReference.js'; - -export default function createBinding ( generator, node, attribute, current, local ) { - const parts = attribute.value.split( '.' ); - - const deep = parts.length > 1; - const contextual = parts[0] in current.contexts; - - if ( contextual && !~local.allUsedContexts.indexOf( parts[0] ) ) { - local.allUsedContexts.push( parts[0] ); - } - - if ( local.isComponent ) { - let obj; - let prop; - let value; - - if ( contextual ) { - obj = current.listNames[ parts[0] ]; - prop = current.indexNames[ parts[0] ]; - value = attribute.value; - } else { - prop = `'${parts.slice( -1 )}'`; - obj = parts.length > 1 ? `root.${parts.slice( 0, -1 ).join( '.' )}` : `root`; - value = `root.${attribute.value}`; - } - - local.bindings.push({ name: attribute.name, value, obj, prop }); - } - - const handler = current.getUniqueName( `${local.name}ChangeHandler` ); - let setter; - - let eventName = 'change'; - if ( node.name === 'input' ) { - const typeAttribute = node.attributes.find( attr => attr.type === 'Attribute' && attr.name === 'type' ); - const type = typeAttribute ? typeAttribute.value[0].data : 'text'; // TODO in validation, should throw if type attribute is not static - - if ( type !== 'checkbox' && type !== 'radio' ) { - eventName = 'input'; - } - } - - else if ( node.name === 'textarea' ) { - eventName = 'input'; - } - - const isMultipleSelect = node.name === 'select' && node.attributes.find( attr => attr.name.toLowerCase() === 'multiple' ); // TODO ensure that this is a static attribute - let value; - - if ( local.isComponent ) { - value = 'value'; - } else if ( node.name === 'select' ) { - if ( isMultipleSelect ) { - value = `[].map.call( ${local.name}.selectedOptions, function ( option ) { return option.__value; })`; - } else { - value = 'selectedOption && selectedOption.__value'; - } - } else { - value = `${local.name}.${attribute.name}`; - } - - if ( contextual ) { - // find the top-level property that this is a child of - let fragment = current; - let prop = parts[0]; - - do { - if ( fragment.expression && fragment.context === prop ) { - if ( !isReference( fragment.expression ) ) { - // TODO this should happen in prior validation step - throw new Error( `${prop} is read-only, it cannot be bound` ); - } - - prop = flattenReference( fragment.expression ).name; - } - } while ( fragment = fragment.parent ); - - generator.expectedProperties[ prop ] = true; - - const listName = current.listNames[ parts[0] ]; - const indexName = current.indexNames[ parts[0] ]; - - const context = local.isComponent ? `_context` : `__svelte`; - - setter = deindent` - var list = this.${context}.${listName}; - var index = this.${context}.${indexName}; - list[index]${parts.slice( 1 ).map( part => `.${part}` ).join( '' )} = ${value}; - - component._set({ ${prop}: component.get( '${prop}' ) }); - `; - } else { - if ( deep ) { - setter = deindent` - var ${parts[0]} = component.get( '${parts[0]}' ); - ${parts[0]}.${parts.slice( 1 ).join( '.' )} = ${value}; - component._set({ ${parts[0]}: ${parts[0]} }); - `; - } else { - setter = `component._set({ ${attribute.value}: ${value} });`; - } - - generator.expectedProperties[ parts[0] ] = true; - } - - // special case - if ( node.name === 'select' && !isMultipleSelect ) { - setter = `var selectedOption = ${local.name}.selectedOptions[0] || ${local.name}.options[0];\n` + setter; - } - - if ( local.isComponent ) { - generator.hasComplexBindings = true; - - local.init.addBlock( deindent` - var ${local.name}_updating = false; - - component._bindings.push( function () { - if ( ${local.name}._torndown ) return; - ${local.name}.observe( '${attribute.name}', function ( value ) { - ${local.name}_updating = true; - ${setter} - ${local.name}_updating = false; - }); - }); - ` ); - - const dependencies = parts[0] in current.contexts ? current.contextDependencies[ parts[0] ] : [ parts[0] ]; - - local.update.addBlock( deindent` - if ( !${local.name}_updating && ${dependencies.map( dependency => `'${dependency}' in changed` ).join( '||' )} ) { - ${local.name}._set({ ${attribute.name}: ${contextual ? attribute.value : `root.${attribute.value}`} }); - } - ` ); - } else { - let updateElement; - - if ( node.name === 'select' ) { - const value = generator.current.getUniqueName( 'value' ); - const i = generator.current.getUniqueName( 'i' ); - const option = generator.current.getUniqueName( 'option' ); - - const ifStatement = isMultipleSelect ? - deindent` - ${option}.selected = ~${value}.indexOf( ${option}.__value );` : - deindent` - if ( ${option}.__value === ${value} ) { - ${option}.selected = true; - break; - }`; - - updateElement = deindent` - var ${value} = ${contextual ? attribute.value : `root.${attribute.value}`}; - for ( var ${i} = 0; ${i} < ${local.name}.options.length; ${i} += 1 ) { - var ${option} = ${local.name}.options[${i}]; - - ${ifStatement} - } - `; - } else { - updateElement = `${local.name}.${attribute.name} = ${contextual ? attribute.value : `root.${attribute.value}`};`; - } - - local.init.addBlock( deindent` - var ${local.name}_updating = false; - - function ${handler} () { - ${local.name}_updating = true; - ${setter} - ${local.name}_updating = false; - } - - ${generator.helper( 'addEventListener' )}( ${local.name}, '${eventName}', ${handler} ); - ` ); - - node.initialUpdate = updateElement; - - local.update.addLine( deindent` - if ( !${local.name}_updating ) { - ${updateElement} - } - ` ); - - generator.current.builders.teardown.addLine( deindent` - ${generator.helper( 'removeEventListener' )}( ${local.name}, '${eventName}', ${handler} ); - ` ); - } -} diff --git a/src/generators/server-side-rendering/index.js b/src/generators/server-side-rendering/index.js index 285fb3cca6..efe0d13ea7 100644 --- a/src/generators/server-side-rendering/index.js +++ b/src/generators/server-side-rendering/index.js @@ -1,5 +1,6 @@ import deindent from '../../utils/deindent.js'; import CodeBuilder from '../../utils/CodeBuilder.js'; +import flattenReference from '../../utils/flattenReference.js'; import processCss from '../shared/processCss.js'; import visitors from './visitors/index.js'; import Generator from '../Generator.js'; @@ -16,11 +17,13 @@ class SsrGenerator extends Generator { this.current.conditions.map( c => `(${c})` ) ); + const { keypath } = flattenReference( binding.value ); + this.bindings.push( deindent` if ( ${conditions.join( '&&' )} ) { tmp = ${name}.data(); - if ( '${binding.value}' in tmp ) { - root.${binding.name} = tmp.${binding.value}; + if ( '${keypath}' in tmp ) { + root.${binding.name} = tmp.${keypath}; settled = false; } } diff --git a/src/generators/server-side-rendering/visitors/Component.js b/src/generators/server-side-rendering/visitors/Component.js index c44d58fe51..895792a7d0 100644 --- a/src/generators/server-side-rendering/visitors/Component.js +++ b/src/generators/server-side-rendering/visitors/Component.js @@ -1,3 +1,5 @@ +import flattenReference from '../../../utils/flattenReference.js'; + export default { enter ( generator, node ) { function stringify ( chunk ) { @@ -42,8 +44,8 @@ export default { return `${attribute.name}: ${value}`; }) .concat( bindings.map( binding => { - const parts = binding.value.split( '.' ); - const value = parts[0] in generator.current.contexts ? binding.value : `root.${binding.value}`; + const { name, keypath } = flattenReference( binding.value ); + const value = name in generator.current.contexts ? keypath : `root.${keypath}`; return `${binding.name}: ${value}`; })) .join( ', ' ); diff --git a/src/parse/read/directives.js b/src/parse/read/directives.js index 5d62899dc1..9072f9d0a3 100644 --- a/src/parse/read/directives.js +++ b/src/parse/read/directives.js @@ -1,4 +1,5 @@ import { parse } from 'acorn'; +import { parseExpressionAt } from 'acorn'; import spaces from '../../utils/spaces.js'; export function readEventHandlerDirective ( parser, start, name ) { @@ -66,7 +67,7 @@ export function readEventHandlerDirective ( parser, start, name ) { } export function readBindingDirective ( parser, start, name ) { - let value = name; // shorthand – bind:foo equivalent to bind:foo='foo' + let value; if ( parser.eat( '=' ) ) { const quoteMark = ( @@ -75,12 +76,37 @@ export function readBindingDirective ( parser, start, name ) { null ); - value = parser.read( /([a-zA-Z_$][a-zA-Z0-9_$]*)(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*/ ); - if ( !value ) parser.error( `Expected valid property name` ); + const a = parser.index; + + // this is a bit of a hack so that we can give Acorn something parseable + let b; + if ( quoteMark ) { + b = parser.index = parser.template.indexOf( quoteMark, parser.index ); + } else { + parser.readUntil( /[\s\r\n\/>]/ ); + b = parser.index; + } + + const source = spaces( a ) + parser.template.slice( a, b ); + value = parseExpressionAt( source, a ); + + if ( value.type !== 'Identifier' && value.type !== 'MemberExpression' ) { + parser.error( `Expected valid property name` ); + } + + parser.allowWhitespace(); if ( quoteMark ) { parser.eat( quoteMark, true ); } + } else { + // shorthand – bind:foo equivalent to bind:foo='foo' + value = { + type: 'Identifier', + start: start + 5, + end: parser.index, + name + }; } return { diff --git a/src/utils/flattenReference.js b/src/utils/flattenReference.js index c975c65095..da92d5588f 100644 --- a/src/utils/flattenReference.js +++ b/src/utils/flattenReference.js @@ -12,5 +12,5 @@ export default function flatten ( node ) { if ( !name ) return null; parts.unshift( name ); - return { name, keypath: parts.join( '.' ) }; + return { name, parts, keypath: parts.join( '.' ) }; } diff --git a/test/generator/samples/binding-input-checkbox/_config.js b/test/generator/samples/binding-input-checkbox/_config.js index e12eb2ee48..ba943e5d77 100644 --- a/test/generator/samples/binding-input-checkbox/_config.js +++ b/test/generator/samples/binding-input-checkbox/_config.js @@ -2,7 +2,9 @@ export default { data: { foo: true }, + html: `\n

true

`, + test ( assert, component, target, window ) { const input = target.querySelector( 'input' ); assert.equal( input.checked, true ); diff --git a/test/parser/samples/binding-shorthand/output.json b/test/parser/samples/binding-shorthand/output.json index 61c24975a2..ba22c66d33 100644 --- a/test/parser/samples/binding-shorthand/output.json +++ b/test/parser/samples/binding-shorthand/output.json @@ -15,7 +15,12 @@ "end": 16, "type": "Binding", "name": "foo", - "value": "foo" + "value": { + "type": "Identifier", + "start": 13, + "end": 16, + "name": "foo" + } } ], "children": [] diff --git a/test/parser/samples/binding/output.json b/test/parser/samples/binding/output.json index ec1fcd2862..f307779950 100644 --- a/test/parser/samples/binding/output.json +++ b/test/parser/samples/binding/output.json @@ -15,7 +15,12 @@ "end": 24, "type": "Binding", "name": "value", - "value": "name" + "value": { + "start": 19, + "end": 23, + "type": "Identifier", + "name": "name" + } } ], "children": []