diff --git a/src/generators/dom/visitors/Component.ts b/src/generators/dom/visitors/Component.ts index 1a06ebadec..5ac1e36ead 100644 --- a/src/generators/dom/visitors/Component.ts +++ b/src/generators/dom/visitors/Component.ts @@ -7,21 +7,10 @@ import getTailSnippet from '../../../utils/getTailSnippet'; import getObject from '../../../utils/getObject'; import getExpressionPrecedence from '../../../utils/getExpressionPrecedence'; import { stringify } from '../../../utils/stringify'; +import stringifyProps from '../../../utils/stringifyProps'; import { Node } from '../../../interfaces'; import { State } from '../interfaces'; -function stringifyProps(props: string[]) { - if (!props.length) return '{}'; - - const joined = props.join(', '); - if (joined.length > 40) { - // make larger data objects readable - return `{\n\t${props.join(',\n\t')}\n}`; - } - - return `{ ${joined} }`; -} - interface Attribute { name: string; value: any; diff --git a/src/generators/dom/visitors/Element/Binding.ts b/src/generators/dom/visitors/Element/Binding.ts deleted file mode 100644 index 6be11e86a5..0000000000 --- a/src/generators/dom/visitors/Element/Binding.ts +++ /dev/null @@ -1,349 +0,0 @@ -import deindent from '../../../../utils/deindent'; -import flattenReference from '../../../../utils/flattenReference'; -import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; -import { DomGenerator } from '../../index'; -import Block from '../../Block'; -import { Node } from '../../../../interfaces'; -import { State } from '../../interfaces'; -import getObject from '../../../../utils/getObject'; -import getTailSnippet from '../../../../utils/getTailSnippet'; - -const readOnlyMediaAttributes = new Set([ - 'duration', - 'buffered', - 'seekable', - 'played' -]); - -export default function visitBinding( - generator: DomGenerator, - block: Block, - state: State, - node: Node, - attribute: Node -) { - const { name } = getObject(attribute.value); - const { snippet, contexts, dependencies } = block.contextualise( - attribute.value - ); - - contexts.forEach(context => { - if (!~state.allUsedContexts.indexOf(context)) - state.allUsedContexts.push(context); - }); - - const eventNames = getBindingEventName(node, attribute); - const handler = block.getUniqueName( - `${state.parentNode}_${eventNames.join('_')}_handler` - ); - const isMultipleSelect = - node.name === 'select' && - node.attributes.find( - (attr: Node) => attr.name.toLowerCase() === 'multiple' - ); // TODO use getStaticAttributeValue - const type = getStaticAttributeValue(node, 'type'); - const bindingGroup = attribute.name === 'group' - ? getBindingGroup(generator, attribute.value) - : null; - - const isMediaElement = node.name === 'audio' || node.name === 'video'; - const isReadOnly = isMediaElement && readOnlyMediaAttributes.has(attribute.name) - - const value = getBindingValue( - generator, - block, - state, - node, - attribute, - isMultipleSelect, - isMediaElement, - bindingGroup, - type - ); - - let setter = getSetter(generator, block, name, snippet, state.parentNode, attribute, dependencies, value); - let updateElement = `${state.parentNode}.${attribute.name} = ${snippet};`; - - const needsLock = !isReadOnly && node.name !== 'input' || !/radio|checkbox|range|color/.test(type); // TODO others? - const lock = `#${state.parentNode}_updating`; - let updateConditions = needsLock ? [`!${lock}`] : []; - - if (needsLock) block.addVariable(lock, 'false'); - - // special case - if (type === 'radio') { - setter = deindent` - if (!${state.parentNode}.checked) return; - ${setter} - `; - } - - const condition = type === 'checkbox' - ? `~${snippet}.indexOf(${state.parentNode}.__value)` - : `${state.parentNode}.__value === ${snippet}`; - - block.builders.hydrate.addLine( - `#component._bindingGroups[${bindingGroup}].push(${state.parentNode});` - ); - - block.builders.destroy.addBlock( - `#component._bindingGroups[${bindingGroup}].splice(#component._bindingGroups[${bindingGroup}].indexOf(${state.parentNode}), 1);` - ); - - updateElement = `${state.parentNode}.checked = ${condition};`; - } else if (isMediaElement) { - generator.hasComplexBindings = true; - block.builders.hydrate.addBlock(`#component._root._beforecreate.push(${handler});`); - - if (attribute.name === 'currentTime') { - const frame = block.getUniqueName(`${state.parentNode}_animationframe`); - block.addVariable(frame); - setter = deindent` - cancelAnimationFrame(${frame}); - if (!${state.parentNode}.paused) ${frame} = requestAnimationFrame(${handler}); - ${setter} - `; - - updateConditions.push(`!isNaN(${snippet})`); - } else if (attribute.name === 'paused') { - // this is necessary to prevent the audio restarting by itself - const last = block.getUniqueName(`${state.parentNode}_paused_value`); - block.addVariable(last, 'true'); - - updateConditions = [`${last} !== (${last} = ${snippet})`]; - updateElement = `${state.parentNode}[${last} ? "pause" : "play"]();`; - } - } - - block.builders.init.addBlock(deindent` - function ${handler}() { - ${needsLock && `${lock} = true;`} - ${setter} - ${needsLock && `${lock} = false;`} - } - `); - - if (node.name === 'input' && type === 'range') { - // need to bind to `input` and `change`, for the benefit of IE - block.builders.hydrate.addBlock(deindent` - @addListener(${state.parentNode}, "input", ${handler}); - @addListener(${state.parentNode}, "change", ${handler}); - `); - - block.builders.destroy.addBlock(deindent` - @removeListener(${state.parentNode}, "input", ${handler}); - @removeListener(${state.parentNode}, "change", ${handler}); - `); - } else { - eventNames.forEach(eventName => { - block.builders.hydrate.addLine( - `@addListener(${state.parentNode}, "${eventName}", ${handler});` - ); - - block.builders.destroy.addLine( - `@removeListener(${state.parentNode}, "${eventName}", ${handler});` - ); - }); - } - - if (!isMediaElement) { - node.initialUpdate = updateElement; - node.initialUpdateNeedsStateObject = !block.contexts.has(name); - } - - if (!isReadOnly) { // audio/video duration is read-only, it never updates - if (updateConditions.length) { - block.builders.update.addBlock(deindent` - if (${updateConditions.join(' && ')}) { - ${updateElement} - } - `); - } else { - block.builders.update.addBlock(deindent` - ${updateElement} - `); - } - } - - if (attribute.name === 'paused') { - block.builders.create.addLine( - `@addListener(${state.parentNode}, "play", ${handler});` - ); - block.builders.destroy.addLine( - `@removeListener(${state.parentNode}, "play", ${handler});` - ); - } -} - -function getBindingEventName(node: Node, attribute: Node) { - if (node.name === 'input') { - const typeAttribute = node.attributes.find( - (attr: Node) => 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 - - return [type === 'checkbox' || type === 'radio' ? 'change' : 'input']; - } - - if (node.name === 'textarea') return ['input']; - if (attribute.name === 'currentTime') return ['timeupdate']; - if (attribute.name === 'duration') return ['durationchange']; - if (attribute.name === 'paused') return ['pause']; - if (attribute.name === 'buffered') return ['progress', 'loadedmetadata']; - if (attribute.name === 'seekable') return ['loadedmetadata']; - if (attribute.name === 'played') return ['timeupdate']; - - return ['change']; -} - -function getBindingValue( - generator: DomGenerator, - block: Block, - state: State, - node: Node, - attribute: Node, - isMultipleSelect: boolean, - isMediaElement: boolean, - bindingGroup: number, - type: string -) { - // - if (type === 'range' || type === 'number') { - return `@toNumber(${state.parentNode}.${attribute.name})`; - } - - if (isMediaElement && (attribute.name === 'buffered' || attribute.name === 'seekable' || attribute.name === 'played')) { - return `@timeRangesToArray(${state.parentNode}.${attribute.name})` - } - - // everything else - return `${state.parentNode}.${attribute.name}`; -} - -function getBindingGroup(generator: DomGenerator, value: Node) { - const { parts } = flattenReference(value); // TODO handle cases involving computed member expressions - const keypath = parts.join('.'); - - // TODO handle contextual bindings — `keypath` should include unique ID of - // each block that provides context - let index = generator.bindingGroups.indexOf(keypath); - if (index === -1) { - index = generator.bindingGroups.length; - generator.bindingGroups.push(keypath); - } - - return index; -} - -function getSetter( - generator: DomGenerator, - block: Block, - name: string, - snippet: string, - _this: string, - attribute: Node, - dependencies: string[], - value: string, -) { - const tail = attribute.value.type === 'MemberExpression' - ? getTailSnippet(attribute.value) - : ''; - - if (block.contexts.has(name)) { - const prop = dependencies[0]; - const computed = isComputed(attribute.value); - - return deindent` - var list = ${_this}._svelte.${block.listNames.get(name)}; - var index = ${_this}._svelte.${block.indexNames.get(name)}; - ${computed && `var state = #component.get();`} - list[index]${tail} = ${value}; - - ${computed - ? `#component.set({${dependencies.map((prop: string) => `${prop}: state.${prop}`).join(', ')} });` - : `#component.set({${dependencies.map((prop: string) => `${prop}: #component.get('${prop}')`).join(', ')} });`} - `; - } - - if (attribute.value.type === 'MemberExpression') { - // This is a little confusing, and should probably be tidied up - // at some point. It addresses a tricky bug (#893), wherein - // Svelte tries to `set()` a computed property, which throws an - // error in dev mode. a) it's possible that we should be - // replacing computations with *their* dependencies, and b) - // we should probably populate `generator.readonly` sooner so - // that we don't have to do the `.some()` here - dependencies = dependencies.filter(prop => !generator.computations.some(computation => computation.key === prop)); - - return deindent` - var state = #component.get(); - ${snippet} = ${value}; - #component.set({ ${dependencies.map((prop: string) => `${prop}: state.${prop}`).join(', ')} }); - `; - } - - return `#component.set({ ${name}: ${value} });`; -} - -function isComputed(node: Node) { - while (node.type === 'MemberExpression') { - if (node.computed) return true; - node = node.object; - } - - return false; -} diff --git a/src/generators/dom/visitors/Element/Element.ts b/src/generators/dom/visitors/Element/Element.ts index f678dec9e8..8de38c766d 100644 --- a/src/generators/dom/visitors/Element/Element.ts +++ b/src/generators/dom/visitors/Element/Element.ts @@ -4,9 +4,9 @@ import visitSlot from '../Slot'; import visitComponent from '../Component'; import visitWindow from './meta/Window'; import visitAttribute from './Attribute'; -import visitEventHandler from './EventHandler'; -import visitBinding from './Binding'; -import visitRef from './Ref'; +import addBindings from './addBindings'; +import flattenReference from '../../../../utils/flattenReference'; +import validCalleeObjects from '../../../../utils/validCalleeObjects'; import * as namespaces from '../../../../utils/namespaces'; import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; import isVoidElementName from '../../../../utils/isVoidElementName'; @@ -18,24 +18,10 @@ import { State } from '../../interfaces'; import reservedNames from '../../../../utils/reservedNames'; import { stringify } from '../../../../utils/stringify'; -const meta = { +const meta: Record = { ':Window': visitWindow, }; -const order = { - Attribute: 1, - Binding: 2, - EventHandler: 3, - Ref: 4, -}; - -const visitors = { - Attribute: visitAttribute, - EventHandler: visitEventHandler, - Binding: visitBinding, - Ref: visitRef, -}; - export default function visitElement( generator: DomGenerator, block: Block, @@ -69,8 +55,6 @@ export default function visitElement( `${componentStack[componentStack.length - 1].var}._slotted.${slot.value[0].data}` : // TODO this looks bonkers state.parentNode; - const isToplevel = !parentNode; - block.addVariable(name); block.builders.create.addLine( `${name} = ${getRenderStatement( @@ -93,6 +77,10 @@ export default function visitElement( ); } else { block.builders.mount.addLine(`@insertNode(${name}, #target, anchor);`); + + // TODO we eventually need to consider what happens to elements + // that belong to the same outgroup as an outroing element... + block.builders.unmount.addLine(`@detachNode(${name});`); } // add CSS encapsulation attribute @@ -109,105 +97,191 @@ export default function visitElement( } } - function visitAttributesAndAddProps() { - let intro; - let outro; - - node.attributes - .sort((a: Node, b: Node) => order[a.type] - order[b.type]) - .forEach((attribute: Node) => { - if (attribute.type === 'Transition') { - if (attribute.intro) intro = attribute; - if (attribute.outro) outro = attribute; - return; - } - - visitors[attribute.type](generator, block, childState, node, attribute); + if (node.name === 'textarea') { + // this is an egregious hack, but it's the easiest way to get