diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index 72edbe8d0e..25813e0c4b 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -845,6 +845,10 @@ export default class Generator { if (node.type === 'Component' && node.name === ':Component') { node.metadata = contextualise(node.expression, contextDependencies, indexes, false); } + + if (node.type === 'Spread') { + node.metadata = contextualise(node.expression, contextDependencies, indexes, false); + } }, leave(node: Node, parent: Node) { diff --git a/src/generators/nodes/Component.ts b/src/generators/nodes/Component.ts index 481c7de0e3..47eb8175f0 100644 --- a/src/generators/nodes/Component.ts +++ b/src/generators/nodes/Component.ts @@ -48,6 +48,10 @@ export default class Component extends Node { } }); + if (this.spread) { + block.addDependencies(this.spread.metadata.dependencies); + } + this.var = block.getUniqueName( ( this.name === ':Self' ? this.generator.name : @@ -76,6 +80,7 @@ export default class Component extends Node { const name = this.var; const componentInitProperties = [`root: #component.root`]; + let componentInitialData = null; if (this.children.length > 0) { const slots = Array.from(this._slots).map(name => `${quoteIfNecessary(name, generator.legacy)}: @createFragment()`); @@ -225,7 +230,7 @@ export default class Component extends Node { } }); - componentInitProperties.push(`data: ${name_initial_data}`); + componentInitialData = name_initial_data; const initialisers = [ 'state = #component.get()', @@ -249,10 +254,21 @@ export default class Component extends Node { }); `; } else if (initialProps.length) { - componentInitProperties.push(`data: ${initialPropString}`); + componentInitialData = initialPropString; } } + if (this.spread) { + const initialData = this.spread.renderForComponent(block, updates); + componentInitialData = componentInitialData ? + `@assign({}, ${initialData}, ${componentInitialData})` : + initialData; + } + + if (componentInitialData) { + componentInitProperties.push(`data: ${componentInitialData}`); + } + const isDynamicComponent = this.name === ':Component'; const switch_vars = isDynamicComponent && { @@ -555,4 +571,4 @@ function isComputed(node: Node) { } return false; -} \ No newline at end of file +} diff --git a/src/generators/nodes/Element.ts b/src/generators/nodes/Element.ts index 288a508758..a96cbb7265 100644 --- a/src/generators/nodes/Element.ts +++ b/src/generators/nodes/Element.ts @@ -141,6 +141,10 @@ export default class Element extends Node { component._slots.add(slot); } + if (this.spread) { + block.addDependencies(this.spread.metadata.dependencies); + } + this.var = block.getUniqueName( this.name.replace(/[^a-zA-Z0-9_$]/g, '_') ); @@ -244,6 +248,10 @@ export default class Element extends Node { this.addTransitions(block); this.addActions(block); + if (this.spread) { + this.spread.renderForElement(block); + } + if (allUsedContexts.size || eventHandlerUsesComponent) { const initialProps: string[] = []; const updates: string[] = []; diff --git a/src/generators/nodes/Spread.ts b/src/generators/nodes/Spread.ts new file mode 100644 index 0000000000..732a9380ad --- /dev/null +++ b/src/generators/nodes/Spread.ts @@ -0,0 +1,200 @@ +import deindent from '../../utils/deindent'; +import { DomGenerator } from '../dom/index'; +import Node from './shared/Node'; +import Element from './Element'; +import Block from '../dom/Block'; + +export default class Spread { + type: 'Spread'; + start: number; + end: number; + + generator: DomGenerator; + parent: Element; + expression: Node; + + metadata: { + dependencies: string[]; + snippet: string; + }; + + constructor({ + generator, + expression, + parent + }: { + generator: DomGenerator, + expression: Node, + parent: Element + }) { + this.type = 'Spread'; + this.generator = generator; + this.parent = parent; + + this.expression = expression; + } + + renderForElement(block: Block) { + const node = this.parent; + + const { expression } = this; + const { indexes } = block.contextualise(expression); + const { dependencies, snippet } = this.metadata; + + const value = snippet; + + const hasChangeableIndex = Array.from(indexes).some(index => block.changeableIndexes.get(index)); + + const shouldCache = ( + expression.type !== 'Identifier' || + block.contexts.has(expression.name) || + hasChangeableIndex + ); + + const last = shouldCache && block.getUniqueName(`${node.var}_spread_value`); + + if (shouldCache) block.addVariable(last); + + const init = shouldCache ? `${last} = ${value}` : value; + + const activeKeys = block.getUniqueName(`${node.var}_spread_keys`); + block.addVariable(activeKeys, '{}'); + + const changes = block.getUniqueName(`${node.var}_spread_changes`); + + const hasNamedAttributes = node.attributes.length; + const namedAttributes = block.getUniqueName(`${node.var}_attributes`); + + if (hasNamedAttributes) { + block.builders.init.addBlock(deindent` + var ${namedAttributes} = [${node.attributes.map(attr => `'${attr.name}'`).join(', ')}]; + `) + } + + block.builders.hydrate.addBlock(deindent` + var ${changes} = ${init}; + for (var key in ${changes}) { + ${hasNamedAttributes ? `if (${namedAttributes}.indexOf(key) !== -1) continue;` : ''} + + @setAttribute(${node.var}, key, ${changes}[key]); + ${activeKeys}[key] = true; + } + `); + + if (dependencies.length || hasChangeableIndex) { + const changedCheck = ( + ( block.hasOutroMethod ? `#outroing || ` : '' ) + + dependencies.map(dependency => `changed.${dependency}`).join(' || ') + ); + + const updateCachedValue = `${last} !== (${last} = ${value})`; + + const condition = shouldCache ? + ( dependencies.length ? `(${changedCheck}) && ${updateCachedValue}` : updateCachedValue ) : + changedCheck; + + const oldKeys = block.getUniqueName(`${node.var}_spread_keys_old`); + + const updater = deindent` + var ${oldKeys} = ${activeKeys}; + ${activeKeys} = {}; + + var ${changes} = ${shouldCache ? last : value}; + for (var key in ${changes}) { + ${hasNamedAttributes ? `if (${namedAttributes}.indexOf(key) !== -1) continue;` : ''} + + @setAttribute(${node.var}, key, ${changes}[key]); + + ${activeKeys}[key] = true; + delete ${oldKeys}[key]; + } + + for (var key in ${oldKeys}) { + @removeAttribute(${node.var}, key); + } + `; + + block.builders.update.addConditional( + condition, + updater + ); + } + } + + renderForComponent(block: Block, updates: string[]) { + const node = this.parent; + + + const { expression } = this; + const { indexes } = block.contextualise(expression); + const { dependencies, snippet } = this.metadata; + + const value = snippet; + + const hasChangeableIndex = Array.from(indexes).some(index => block.changeableIndexes.get(index)); + + const shouldCache = ( + expression.type !== 'Identifier' || + block.contexts.has(expression.name) || + hasChangeableIndex + ); + + const last = shouldCache && block.getUniqueName(`${node.var}_spread_value`); + + if (shouldCache) block.addVariable(last); + + const init = shouldCache ? `${last} = ${value}` : value; + + const activeKeys = block.getUniqueName(`${node.var}_spread_keys`); + block.addVariable(activeKeys, '{}'); + + const changes = block.getUniqueName(`${node.var}_spread_changes`); + + const hasNamedAttributes = node.attributes.length; + const namedAttributes = block.getUniqueName(`${node.var}_attributes`); + + if (hasNamedAttributes) { + block.builders.init.addBlock(deindent` + var ${namedAttributes} = [${node.attributes.map(attr => `'${attr.name}'`).join(', ')}]; + `) + } + + if (dependencies.length || hasChangeableIndex) { + const changedCheck = ( + ( block.hasOutroMethod ? `#outroing || ` : '' ) + + dependencies.map(dependency => `changed.${dependency}`).join(' || ') + ); + + const updateCachedValue = `${last} !== (${last} = ${value})`; + + const condition = shouldCache ? + ( dependencies.length ? `(${changedCheck}) && ${updateCachedValue}` : updateCachedValue ) : + changedCheck; + + const oldKeys = block.getUniqueName(`${node.var}_spread_keys_old`); + + updates.push(deindent` + if (${condition}) { + var ${oldKeys} = ${activeKeys}; + ${activeKeys} = {}; + + var ${changes} = ${shouldCache ? last : value}; + for (var key in ${changes}) { + ${hasNamedAttributes ? `if (${namedAttributes}.indexOf(key) !== -1) continue;` : ''} + + ${node.var}_changes[key] = ${changes}[key]; + + ${activeKeys}[key] = true; + delete ${oldKeys}[key]; + } + + for (var key in ${oldKeys}) { + ${node.var}_changes[key] = undefined; + } + } + `); + } + + return value; + } +} diff --git a/src/generators/nodes/index.ts b/src/generators/nodes/index.ts index e3c9de874d..842d14777b 100644 --- a/src/generators/nodes/index.ts +++ b/src/generators/nodes/index.ts @@ -18,6 +18,7 @@ import PendingBlock from './PendingBlock'; import RawMustacheTag from './RawMustacheTag'; import Ref from './Ref'; import Slot from './Slot'; +import Spread from './Spread'; import Text from './Text'; import ThenBlock from './ThenBlock'; import Title from './Title'; @@ -44,6 +45,7 @@ const nodes: Record = { RawMustacheTag, Ref, Slot, + Spread, Text, ThenBlock, Title, @@ -51,4 +53,4 @@ const nodes: Record = { Window }; -export default nodes; \ No newline at end of file +export default nodes; diff --git a/src/parse/state/tag.ts b/src/parse/state/tag.ts index 79aa289f20..897478c6dd 100644 --- a/src/parse/state/tag.ts +++ b/src/parse/state/tag.ts @@ -178,6 +178,8 @@ export default function tag(parser: Parser) { parser.allowWhitespace(); } + element.spread = readSpread(parser); + const uniqueNames = new Set(); let attribute; @@ -384,3 +386,27 @@ function readSequence(parser: Parser, done: () => boolean) { parser.error(`Unexpected end of input`); } + +function readSpread(parser: Parser) { + const start = parser.index; + + if (parser.eat('{{...')) { + const expression = readExpression(parser); + parser.allowWhitespace(); + + if (!parser.eat('}}')) { + parser.error(`Expected }}`); + } + + parser.allowWhitespace(); + + return { + start, + end: parser.index, + type: 'Spread', + expression, + }; + } + + return null; +} diff --git a/src/shared/dom.js b/src/shared/dom.js index 9003faee3e..8cde378ad6 100644 --- a/src/shared/dom.js +++ b/src/shared/dom.js @@ -85,6 +85,10 @@ export function setAttribute(node, attribute, value) { node.setAttribute(attribute, value); } +export function removeAttribute(node, attribute) { + node.removeAttribute(attribute); +} + export function setXlinkAttribute(node, attribute, value) { node.setAttributeNS('http://www.w3.org/1999/xlink', attribute, value); } @@ -177,4 +181,4 @@ export function selectMultipleValue(select) { return [].map.call(select.querySelectorAll(':checked'), function(option) { return option.__value; }); -} \ No newline at end of file +} diff --git a/test/parser/samples/spread/input.html b/test/parser/samples/spread/input.html new file mode 100644 index 0000000000..b5a2c79600 --- /dev/null +++ b/test/parser/samples/spread/input.html @@ -0,0 +1 @@ +
diff --git a/test/parser/samples/spread/output.json b/test/parser/samples/spread/output.json new file mode 100644 index 0000000000..b4ae6a7e01 --- /dev/null +++ b/test/parser/samples/spread/output.json @@ -0,0 +1,31 @@ +{ + "hash": "phg0l6", + "html": { + "start": 0, + "end": 24, + "type": "Fragment", + "children": [ + { + "start": 0, + "end": 24, + "type": "Element", + "name": "div", + "attributes": [], + "children": [], + "spread": { + "start": 5, + "end": 17, + "type": "Spread", + "expression": { + "type": "Identifier", + "start": 10, + "end": 15, + "name": "props" + } + } + } + ] + }, + "css": null, + "js": null +} diff --git a/test/runtime/samples/spread-component/Widget.html b/test/runtime/samples/spread-component/Widget.html new file mode 100644 index 0000000000..43184b9ad8 --- /dev/null +++ b/test/runtime/samples/spread-component/Widget.html @@ -0,0 +1,4 @@ +

foo: {{foo}}

+

baz: {{baz}} ({{typeof baz}})

+

qux: {{qux}}

+

quux: {{quux}}

diff --git a/test/runtime/samples/spread-component/_config.js b/test/runtime/samples/spread-component/_config.js new file mode 100644 index 0000000000..66df4c04e7 --- /dev/null +++ b/test/runtime/samples/spread-component/_config.js @@ -0,0 +1,27 @@ +export default { + solo: true, + + data: { + props: { + foo: 'lol', + baz: 40 + 2, + qux: `this is a ${'piece of'} string`, + quux: 'core' + } + }, + + html: `

foo: lol

\n

baz: 42 (number)

\n

qux: named

\n

quux: core

`, + + test ( assert, component, target ) { + component.set({ + props: { + foo: 'wut', + baz: 40 + 3, + qux: `this is a ${'rather boring'} string`, + quux: 'heart' + } + }); + + assert.equal( target.innerHTML, `

foo: wut

\n

baz: 43 (number)

\n

qux: named

\n

quux: heart

` ); + } +}; diff --git a/test/runtime/samples/spread-component/main.html b/test/runtime/samples/spread-component/main.html new file mode 100644 index 0000000000..e6b16a4429 --- /dev/null +++ b/test/runtime/samples/spread-component/main.html @@ -0,0 +1,11 @@ +
+ +
+ + diff --git a/test/runtime/samples/spread-element/_config.js b/test/runtime/samples/spread-element/_config.js new file mode 100644 index 0000000000..7ffe4ae71d --- /dev/null +++ b/test/runtime/samples/spread-element/_config.js @@ -0,0 +1,21 @@ +export default { + solo: true, + + html: `
red
`, + + test ( assert, component, target ) { + const div = target.querySelector( 'div' ); + + assert.equal( div.dataset.foo, 'bar' ); + assert.equal( div.dataset.named, 'value' ); + + component.set({ color: 'blue', props: { 'data-foo': 'baz', 'data-named': 'qux' } }); + assert.equal( target.innerHTML, `
blue
` ); + assert.equal( div.dataset.foo, 'baz' ); + assert.equal( div.dataset.named, 'value' ); + + component.set({ color: 'blue', props: {} }); + assert.equal( target.innerHTML, `
blue
` ); + assert.equal( div.dataset.foo, undefined ); + } +}; diff --git a/test/runtime/samples/spread-element/main.html b/test/runtime/samples/spread-element/main.html new file mode 100644 index 0000000000..8f3dbae7d5 --- /dev/null +++ b/test/runtime/samples/spread-element/main.html @@ -0,0 +1,13 @@ +
{{color}}
+ +