Merge pull request #971 from sveltejs/gh-640

Implement dynamic components
pull/1004/head
Rich Harris 7 years ago committed by GitHub
commit 1767455c33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -763,6 +763,10 @@ export default class Generator {
node.metadata = contextualise(node.expression, contextDependencies, indexes);
this.skip();
}
if (node.type === 'Element' && node.name === ':Component') {
node.metadata = contextualise(node.expression, contextDependencies, indexes);
}
},
leave(node: Node, parent: Node) {

@ -256,6 +256,7 @@ const preprocessors = {
) => {
cannotUseInnerHTML(node);
node.var = block.getUniqueName(`each`);
node.iterations = block.getUniqueName(`${node.var}_blocks`);
const { dependencies } = node.metadata;
block.addDependencies(dependencies);
@ -436,13 +437,17 @@ const preprocessors = {
}
const isComponent =
generator.components.has(node.name) || node.name === ':Self';
generator.components.has(node.name) || node.name === ':Self' || node.name === ':Component';
if (isComponent) {
cannotUseInnerHTML(node);
node.var = block.getUniqueName(
(node.name === ':Self' ? generator.name : node.name).toLowerCase()
(
node.name === ':Self' ? generator.name :
node.name === ':Component' ? 'switch_instance' :
node.name
).toLowerCase()
);
node._state = getChildState(state, {

@ -3,9 +3,11 @@ import CodeBuilder from '../../../utils/CodeBuilder';
import visit from '../visit';
import { DomGenerator } from '../index';
import Block from '../Block';
import isDomNode from './shared/isDomNode';
import getTailSnippet from '../../../utils/getTailSnippet';
import getObject from '../../../utils/getObject';
import getExpressionPrecedence from '../../../utils/getExpressionPrecedence';
import getStaticAttributeValue from '../../../utils/getStaticAttributeValue';
import { stringify } from '../../../utils/stringify';
import stringifyProps from '../../../utils/stringifyProps';
import { Node } from '../../../interfaces';
@ -60,12 +62,21 @@ export default function visitComponent(
let beforecreate: string = null;
const attributes = node.attributes
.filter((a: Node) => a.type === 'Attribute')
.map((a: Node) => mungeAttribute(a, block));
.filter(a => a.type === 'Attribute')
.map(a => mungeAttribute(a, block));
const bindings = node.attributes
.filter((a: Node) => a.type === 'Binding')
.map((a: Node) => mungeBinding(a, block));
.filter(a => a.type === 'Binding')
.map(a => mungeBinding(a, block));
const eventHandlers = node.attributes
.filter((a: Node) => a.type === 'EventHandler')
.map(a => mungeEventHandler(generator, node, a, block, name_context, allContexts));
const ref = node.attributes.find((a: Node) => a.type === 'Ref');
if (ref) generator.usesRefs = true;
const updates: string[] = [];
if (attributes.length || bindings.length) {
const initialProps = attributes
@ -73,8 +84,6 @@ export default function visitComponent(
const initialPropString = stringifyProps(initialProps);
const updates: string[] = [];
attributes
.filter((attribute: Attribute) => attribute.dynamic)
.forEach((attribute: Attribute) => {
@ -194,96 +203,161 @@ export default function visitComponent(
} else if (initialProps.length) {
componentInitProperties.push(`data: ${initialPropString}`);
}
if (updates.length) {
block.builders.update.addBlock(deindent`
var ${name}_changes = {};
${updates.join('\n')}
${name}._set(${name}_changes);
${bindings.length && `${name_updating} = {};`}
`);
}
const isDynamicComponent = node.name === ':Component';
const switch_vars = isDynamicComponent && {
value: block.getUniqueName('switch_value'),
props: block.getUniqueName('switch_props')
};
const expression = (
node.name === ':Self' ? generator.name :
isDynamicComponent ? switch_vars.value :
`%components-${node.name}`
);
if (isDynamicComponent) {
block.contextualise(node.expression);
const { dependencies, snippet } = node.metadata;
const needsAnchor = node.next ? !isDomNode(node.next, generator) : !state.parentNode || !isDomNode(node.parent, generator);
const anchor = needsAnchor
? block.getUniqueName(`${name}_anchor`)
: (node.next && node.next.var) || 'null';
if (needsAnchor) {
block.addElement(
anchor,
`@createComment()`,
`@createComment()`,
state.parentNode
);
}
const expression = node.name === ':Self' ? generator.name : `%components-${node.name}`;
const params = block.params.join(', ');
block.builders.init.addBlock(deindent`
${statements.join('\n')}
var ${name} = new ${expression}({
var ${switch_vars.value} = ${snippet};
function ${switch_vars.props}(${params}) {
return {
${componentInitProperties.join(',\n')}
});
};
}
if (${switch_vars.value}) {
${statements.length > 0 && statements.join('\n')}
var ${name} = new ${expression}(${switch_vars.props}(${params}));
${beforecreate}
}
${eventHandlers.map(handler => deindent`
function ${handler.var}(event) {
${handler.body}
}
if (${name}) ${name}.on("${handler.name}", ${handler.var});
`)}
`);
block.builders.create.addLine(`${name}._fragment.c();`);
block.builders.create.addLine(
`if (${name}) ${name}._fragment.c();`
);
block.builders.claim.addLine(
`${name}._fragment.l(${state.parentNodes});`
`if (${name}) ${name}._fragment.l(${state.parentNodes});`
);
block.builders.mount.addLine(
`${name}._mount(${state.parentNode || '#target'}, ${state.parentNode ? 'null' : 'anchor'});`
`if (${name}) ${name}._mount(${state.parentNode || '#target'}, ${state.parentNode ? 'null' : 'anchor'});`
);
if (!state.parentNode) block.builders.unmount.addLine(`${name}._unmount();`);
block.builders.update.addBlock(deindent`
if (${switch_vars.value} !== (${switch_vars.value} = ${snippet})) {
if (${name}) ${name}.destroy();
block.builders.destroy.addLine(`${name}.destroy(false);`);
if (${switch_vars.value}) {
${name} = new ${switch_vars.value}(${switch_vars.props}(${params}));
${name}._fragment.c();
// event handlers
node.attributes.filter((a: Node) => a.type === 'EventHandler').forEach((handler: Node) => {
const usedContexts: string[] = [];
${node.children.map(child => remount(generator, child, name))}
${name}._mount(${anchor}.parentNode, ${anchor});
if (handler.expression) {
generator.addSourcemapLocations(handler.expression);
generator.code.prependRight(
handler.expression.start,
`${block.alias('component')}.`
);
${eventHandlers.map(handler => deindent`
${name}.on("${handler.name}", ${handler.var});
`)}
handler.expression.arguments.forEach((arg: Node) => {
const { contexts } = block.contextualise(arg, null, true);
${ref && `#component.refs.${ref.name} = ${name};`}
}
contexts.forEach(context => {
if (!~usedContexts.indexOf(context)) usedContexts.push(context);
allContexts.add(context);
});
});
${ref && deindent`
else if (#component.refs.${ref.name} === ${name}) {
#component.refs.${ref.name} = null;
}`}
}
`);
// TODO hoist event handlers? can do `this.__component.method(...)`
const declarations = usedContexts.map(name => {
if (name === 'state') return `var state = ${name_context}.state;`;
if (updates.length) {
block.builders.update.addBlock(deindent`
else {
var ${name}_changes = {};
${updates.join('\n')}
${name}._set(${name}_changes);
${bindings.length && `${name_updating} = {};`}
}
`);
}
const listName = block.listNames.get(name);
const indexName = block.indexNames.get(name);
if (!state.parentNode) block.builders.unmount.addLine(`if (${name}) ${name}._unmount();`);
return `var ${listName} = ${name_context}.${listName}, ${indexName} = ${name_context}.${indexName}, ${name} = ${listName}[${indexName}]`;
block.builders.destroy.addLine(`if (${name}) ${name}.destroy(false);`);
} else {
block.builders.init.addBlock(deindent`
${statements.join('\n')}
var ${name} = new ${expression}({
${componentInitProperties.join(',\n')}
});
const handlerBody =
(declarations.length ? declarations.join('\n') + '\n\n' : '') +
(handler.expression ?
`[✂${handler.expression.start}-${handler.expression.end}✂];` :
`${block.alias('component')}.fire('${handler.name}', event);`);
${beforecreate}
block.builders.init.addBlock(deindent`
${eventHandlers.map(handler => deindent`
${name}.on("${handler.name}", function(event) {
${handlerBody}
${handler.body}
});
`)}
${ref && `#component.refs.${ref.name} = ${name};`}
`);
});
// refs
node.attributes.filter((a: Node) => a.type === 'Ref').forEach((ref: Node) => {
generator.usesRefs = true;
block.builders.create.addLine(`${name}._fragment.c();`);
block.builders.claim.addLine(
`${name}._fragment.l(${state.parentNodes});`
);
block.builders.mount.addLine(
`${name}._mount(${state.parentNode || '#target'}, ${state.parentNode ? 'null' : 'anchor'});`
);
if (updates.length) {
block.builders.update.addBlock(deindent`
var ${name}_changes = {};
${updates.join('\n')}
${name}._set(${name}_changes);
${bindings.length && `${name_updating} = {};`}
`);
}
block.builders.init.addLine(`#component.refs.${ref.name} = ${name};`);
if (!state.parentNode) block.builders.unmount.addLine(`${name}._unmount();`);
block.builders.destroy.addLine(deindent`
if (#component.refs.${ref.name} === ${name}) #component.refs.${ref.name} = null;
${name}.destroy(false);
${ref && `if (#component.refs.${ref.name} === ${name}) #component.refs.${ref.name} = null;`}
`);
});
}
// maintain component context
if (allContexts.size) {
@ -427,6 +501,55 @@ function mungeBinding(binding: Node, block: Block): Binding {
};
}
function mungeEventHandler(generator: DomGenerator, node: Node, handler: Node, block: Block, name_context: string, allContexts: Set<string>) {
let body;
if (handler.expression) {
generator.addSourcemapLocations(handler.expression);
generator.code.prependRight(
handler.expression.start,
`${block.alias('component')}.`
);
const usedContexts: string[] = [];
handler.expression.arguments.forEach((arg: Node) => {
const { contexts } = block.contextualise(arg, null, true);
contexts.forEach(context => {
if (!~usedContexts.indexOf(context)) usedContexts.push(context);
allContexts.add(context);
});
});
// TODO hoist event handlers? can do `this.__component.method(...)`
const declarations = usedContexts.map(name => {
if (name === 'state') return `var state = ${name_context}.state;`;
const listName = block.listNames.get(name);
const indexName = block.indexNames.get(name);
return `var ${listName} = ${name_context}.${listName}, ${indexName} = ${name_context}.${indexName}, ${name} = ${listName}[${indexName}]`;
});
body = deindent`
${declarations}
[${handler.expression.start}-${handler.expression.end}];
`;
} else {
body = deindent`
${block.alias('component')}.fire('${handler.name}', event);
`;
}
return {
name: handler.name,
var: block.getUniqueName(`${node.var}_${handler.name}`),
body
};
}
function isComputed(node: Node) {
while (node.type === 'MemberExpression') {
if (node.computed) return true;
@ -435,3 +558,31 @@ function isComputed(node: Node) {
return false;
}
function remount(generator: DomGenerator, node: Node, name: string) {
// TODO make this a method of the nodes
if (node.type === 'Element') {
if (node.name === ':Self' || node.name === ':Component' || generator.components.has(node.name)) {
return `${node.var}._mount(${name}._slotted.default, null);`;
}
const slot = node.attributes.find(attribute => attribute.name === 'slot');
if (slot) {
return `@appendNode(${node.var}, ${name}._slotted.${getStaticAttributeValue(node, 'slot')});`;
}
return `@appendNode(${node.var}, ${name}._slotted.default);`;
}
if (node.type === 'Text' || node.type === 'MustacheTag' || node.type === 'RawMustacheTag') {
return `@appendNode(${node.var}, ${name}._slotted.default);`;
}
if (node.type === 'EachBlock') {
// TODO consider keyed blocks
return `for (var #i = 0; #i < ${node.iterations}.length; #i += 1) ${node.iterations}[#i].m(${name}._slotted.default, null);`;
}
return `${node.var}.m(${name}._slotted.default, null);`;
}

@ -18,7 +18,7 @@ export default function visitEachBlock(
const create_each_block = node._block.name;
const each_block_value = node._block.listName;
const iterations = block.getUniqueName(`${each}_blocks`);
const iterations = node.iterations;
const params = block.params.join(', ');
const needsAnchor = node.next ? !isDomNode(node.next, generator) : !state.parentNode || !isDomNode(node.parent, generator);

@ -43,7 +43,7 @@ export default function visitElement(
}
}
if (generator.components.has(node.name) || node.name === ':Self') {
if (generator.components.has(node.name) || node.name === ':Self' || node.name === ':Component') {
return visitComponent(generator, block, state, node, elementStack, componentStack);
}

@ -189,6 +189,14 @@ export default function ssr(
}
`
}
${
/__missingComponent/.test(generator.renderCode) && deindent`
var __missingComponent = {
render: () => ''
};
`
}
`.replace(/(@+|#+|%+)(\w*(?:-\w*)?)/g, (match: string, sigil: string, name: string) => {
if (sigil === '@') return generator.alias(name);
if (sigil === '%') return generator.templateVars.get(name);

@ -71,7 +71,14 @@ export default function visitComponent(
)
.join(', ');
const expression = node.name === ':Self' ? generator.name : `%components-${node.name}`;
const isDynamicComponent = node.name === ':Component';
if (isDynamicComponent) block.contextualise(node.expression);
const expression = (
node.name === ':Self' ? generator.name :
isDynamicComponent ? `((${node.metadata.snippet}) || __missingComponent)` :
`%components-${node.name}`
);
bindings.forEach(binding => {
block.addBinding(binding, expression);

@ -40,7 +40,7 @@ export default function visitElement(
return;
}
if (generator.components.has(node.name) || node.name === ':Self') {
if (generator.components.has(node.name) || node.name === ':Self' || node.name === ':Component') {
visitComponent(generator, block, node);
return;
}

@ -4,7 +4,7 @@ import Generator from '../../Generator';
export default function isChildOfComponent(node: Node, generator: Generator) {
while (node = node.parent) {
if (node.type !== 'Element') continue;
if (generator.components.has(node.name)) return true;
if (node.name === ':Self' || node.name === ':Component' || generator.components.has(node.name)) return true; // TODO extract this out into a helper
if (/-/.test(node.name)) return false;
}
}

@ -15,9 +15,10 @@ import { Node } from '../../interfaces';
const validTagName = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/;
const SELF = ':Self';
const COMPONENT = ':Component';
const metaTags = {
':Window': true,
':Window': true
};
const specials = new Map([
@ -104,6 +105,15 @@ export default function tag(parser: Parser) {
}
}
const element: Node = {
start,
end: null, // filled in later
type: 'Element',
name,
attributes: [],
children: [],
};
parser.allowWhitespace();
if (isClosingTag) {
@ -156,17 +166,22 @@ export default function tag(parser: Parser) {
}
}
const attributes = [];
if (name === COMPONENT) {
parser.eat('{', true);
element.expression = readExpression(parser);
parser.allowWhitespace();
parser.eat('}', true);
parser.allowWhitespace();
}
const uniqueNames = new Set();
let attribute;
while ((attribute = readAttribute(parser, uniqueNames))) {
attributes.push(attribute);
element.attributes.push(attribute);
parser.allowWhitespace();
}
parser.allowWhitespace();
// special cases top-level <script> and <style>
if (specials.has(name) && parser.stack.length === 1) {
const special = specials.get(name);
@ -179,19 +194,10 @@ export default function tag(parser: Parser) {
}
parser.eat('>', true);
parser[special.property] = special.read(parser, start, attributes);
parser[special.property] = special.read(parser, start, element.attributes);
return;
}
const element: Node = {
start,
end: null, // filled in later
type: 'Element',
name,
attributes,
children: [],
};
parser.current().children.push(element);
const selfClosing = parser.eat('/') || isVoidElementName(name);
@ -242,6 +248,8 @@ function readTagName(parser: Parser) {
return SELF;
}
if (parser.eat(COMPONENT)) return COMPONENT;
const name = parser.readUntil(/(\s|\/|>)/);
if (name in metaTags) return name;

@ -14,7 +14,7 @@ export default function validateElement(
elementStack: Node[]
) {
const isComponent =
node.name === ':Self' || validator.components.has(node.name);
node.name === ':Self' || node.name === ':Component' || validator.components.has(node.name);
if (!isComponent && /^[A-Z]/.test(node.name[0])) {
// TODO upgrade to validator.error in v2
@ -230,7 +230,7 @@ function checkSlotAttribute(validator: Validator, node: Node, attribute: Node, s
const parent = stack[i];
if (parent.type === 'Element') {
// if we're inside a component or a custom element, gravy
if (validator.components.has(parent.name)) return;
if (parent.name === ':Self' || parent.name === ':Component' || validator.components.has(parent.name)) return;
if (/-/.test(parent.name)) return;
}

@ -0,0 +1 @@
<:Component {foo ? Foo : Bar}></:Component>

@ -0,0 +1,43 @@
{
"hash": 410218696,
"html": {
"start": 0,
"end": 43,
"type": "Fragment",
"children": [
{
"start": 0,
"end": 43,
"type": "Element",
"name": ":Component",
"attributes": [],
"children": [],
"expression": {
"type": "ConditionalExpression",
"start": 13,
"end": 28,
"test": {
"type": "Identifier",
"start": 13,
"end": 16,
"name": "foo"
},
"consequent": {
"type": "Identifier",
"start": 19,
"end": 22,
"name": "Foo"
},
"alternate": {
"type": "Identifier",
"start": 25,
"end": 28,
"name": "Bar"
}
}
}
]
},
"css": null,
"js": null
}

@ -1,4 +1,5 @@
import assert from "assert";
import chalk from 'chalk';
import * as path from "path";
import * as fs from "fs";
import * as acorn from "acorn";
@ -89,6 +90,9 @@ describe("runtime", () => {
}
} catch (err) {
failed.add(dir);
if (err.frame) {
console.error(chalk.red(err.frame)); // eslint-disable-line no-console
}
showOutput(cwd, { shared, format: 'cjs', store: !!compileOptions.store }, svelte); // eslint-disable-line no-console
throw err;
}

@ -0,0 +1,2 @@
<p>bar</p>
<input type='checkbox' bind:checked='z'>

@ -0,0 +1,2 @@
<p>foo</p>
<input bind:value='y'>

@ -0,0 +1,33 @@
export default {
data: {
x: true
},
html: `
<p>foo</p>
<input>
`,
test(assert, component, target, window) {
let input = target.querySelector('input');
input.value = 'abc';
input.dispatchEvent(new window.Event('input'));
assert.equal(component.get('y'), 'abc');
component.set({
x: false
});
assert.htmlEqual(target.innerHTML, `
<p>bar</p>
<input type='checkbox'>
`);
input = target.querySelector('input');
input.checked = true;
input.dispatchEvent(new window.Event('change'));
assert.equal(component.get('z'), true);
}
};

@ -0,0 +1,12 @@
<:Component { x ? Foo : Bar } bind:y bind:z/>
<script>
import Foo from './Foo.html';
import Bar from './Bar.html';
export default {
data() {
return { Foo, Bar };
}
};
</script>

@ -0,0 +1 @@
<button on:click='fire("select", { id: "bar" })'>select bar</button>

@ -0,0 +1 @@
<button on:click='fire("select", { id: "foo" })'>select foo</button>

@ -0,0 +1,27 @@
export default {
data: {
x: true
},
html: `
<button>select foo</button>
`,
test(assert, component, target, window) {
const click = new window.MouseEvent('click');
target.querySelector('button').dispatchEvent(click);
assert.equal(component.get('selected'), 'foo');
component.set({
x: false
});
assert.htmlEqual(target.innerHTML, `
<button>select bar</button>
`);
target.querySelector('button').dispatchEvent(click);
assert.equal(component.get('selected'), 'bar');
}
};

@ -0,0 +1,12 @@
<:Component { x ? Foo : Bar } on:select='set({ selected: event.id })'/>
<script>
import Foo from './Foo.html';
import Bar from './Bar.html';
export default {
data() {
return { Foo, Bar };
}
};
</script>

@ -0,0 +1,3 @@
<h1>Bar</h1>
<slot></slot>
<slot name='other'></slot>

@ -0,0 +1,3 @@
<h1>Foo</h1>
<slot name='other'></slot>
<slot></slot>

@ -0,0 +1,37 @@
export default {
data: {
x: true
},
html: `
<h1>Foo</h1>
<div slot='other'>what goes up must come down</div>
<p>element</p>
you're it
<p>neither foo nor bar</p>
text
<span>a</span>
<span>b</span>
<span>c</span>
<div>baz</div>
`,
test(assert, component, target) {
component.set({
x: false
});
assert.htmlEqual(target.innerHTML, `
<h1>Bar</h1>
<p>element</p>
you're it
<p>neither foo nor bar</p>
text
<span>a</span>
<span>b</span>
<span>c</span>
<div>baz</div>
<div slot='other'>what goes up must come down</div>
`);
}
};

@ -0,0 +1,45 @@
<:Component { x ? Foo : Bar } x='{{x}}'>
<p>element</p>
{{tag}}
{{#if foo}}
<p>foo</p>
{{elseif bar}}
<p>bar</p>
{{else}}
<p>neither foo nor bar</p>
{{/if}}
text
{{#each things as thing}}
<span>{{thing}}</span>
{{/each}}
<Baz/>
<div slot='other'>what goes up must come down</div>
</:Component>
<script>
import Foo from './Foo.html';
import Bar from './Bar.html';
import Baz from './Baz.html';
export default {
data() {
return {
Foo,
Bar,
tag: 'you\'re it',
things: ['a', 'b', 'c']
};
},
components: {
Baz
}
};
</script>

@ -0,0 +1,19 @@
export default {
data: {
x: 1
},
html: `
<p>Foo 1</p>
`,
test(assert, component, target) {
component.set({
x: 2
});
assert.htmlEqual(target.innerHTML, `
<p>Foo 2</p>
`);
}
};

@ -0,0 +1,12 @@
<:Component { x ? Foo : Bar } x='{{x}}'/>
<script>
import Foo from './Foo.html';
import Bar from './Bar.html';
export default {
data() {
return { Foo, Bar };
}
};
</script>

@ -0,0 +1,19 @@
export default {
data: {
x: true
},
html: `
<p>true, therefore Foo</p>
`,
test(assert, component, target) {
component.set({
x: false
});
assert.htmlEqual(target.innerHTML, `
<p>false, therefore Bar</p>
`);
}
};

@ -0,0 +1,12 @@
<:Component { x ? Foo : Bar } x='{{x}}'/>
<script>
import Foo from './Foo.html';
import Bar from './Bar.html';
export default {
data() {
return { Foo, Bar };
}
};
</script>
Loading…
Cancel
Save