diff --git a/site/content/docs/02-template-syntax.md b/site/content/docs/02-template-syntax.md index d33e59af3161..75ac845938d4 100644 --- a/site/content/docs/02-template-syntax.md +++ b/site/content/docs/02-template-syntax.md @@ -1548,6 +1548,28 @@ If `this` is falsy, no component is rendered. ``` +### `` + +```sv + +``` + +--- + +The `` element lets you render an element of a dynamically specified type. This is useful for example when rich text content from a CMS. If the tag is changed, the children will be preserved unless there's a transition attached to the element. Any properties and event listeners present will be applied to the element. + +The only supported binding is `bind:this`, since the element type specific bindings that Svelte does at build time (e.g. `bind:value` for input elements) does not work with a dynamic tag type. + +If `this` has a nullish value, a warning will be logged in development mode. + +```sv + + +Foo +``` ### `` diff --git a/src/compiler/compile/nodes/Animation.ts b/src/compiler/compile/nodes/Animation.ts index ac9dddd275ad..132688f40223 100644 --- a/src/compiler/compile/nodes/Animation.ts +++ b/src/compiler/compile/nodes/Animation.ts @@ -5,6 +5,7 @@ import TemplateScope from './shared/TemplateScope'; import { TemplateNode } from '../../interfaces'; import Element from './Element'; import EachBlock from './EachBlock'; +import DynamicElement from './DynamicElement'; import compiler_errors from '../compiler_errors'; export default class Animation extends Node { @@ -12,7 +13,7 @@ export default class Animation extends Node { name: string; expression: Expression; - constructor(component: Component, parent: Element, scope: TemplateScope, info: TemplateNode) { + constructor(component: Component, parent: Element | DynamicElement, scope: TemplateScope, info: TemplateNode) { super(component, parent, scope, info); component.warn_if_undefined(info.name, info, scope); diff --git a/src/compiler/compile/nodes/Binding.ts b/src/compiler/compile/nodes/Binding.ts index 1efc1a3038c4..4861ebe60593 100644 --- a/src/compiler/compile/nodes/Binding.ts +++ b/src/compiler/compile/nodes/Binding.ts @@ -10,6 +10,7 @@ import Element from './Element'; import InlineComponent from './InlineComponent'; import Window from './Window'; import { clone } from '../../utils/clone'; +import DynamicElement from './DynamicElement'; import compiler_errors from '../compiler_errors'; // TODO this should live in a specific binding @@ -32,7 +33,7 @@ export default class Binding extends Node { is_contextual: boolean; is_readonly: boolean; - constructor(component: Component, parent: Element | InlineComponent | Window, scope: TemplateScope, info: TemplateNode) { + constructor(component: Component, parent: Element | InlineComponent | Window | DynamicElement, scope: TemplateScope, info: TemplateNode) { super(component, parent, scope, info); if (info.expression.type !== 'Identifier' && info.expression.type !== 'MemberExpression') { diff --git a/src/compiler/compile/nodes/DynamicElement.ts b/src/compiler/compile/nodes/DynamicElement.ts new file mode 100644 index 000000000000..eae94f47e4ef --- /dev/null +++ b/src/compiler/compile/nodes/DynamicElement.ts @@ -0,0 +1,151 @@ +import Node from './shared/Node'; +import Attribute from './Attribute'; +import Binding from './Binding'; +import EventHandler from './EventHandler'; +import Let from './Let'; +import TemplateScope from './shared/TemplateScope'; +import { INode } from './interfaces'; +import Expression from './shared/Expression'; +import Component from '../Component'; +import map_children from './shared/map_children'; +import Class from './Class'; +import Transition from './Transition'; +import Animation from './Animation'; +import Action from './Action'; +import { string_literal } from '../utils/stringify'; +import { Literal } from 'estree'; +import Text from './Text'; + +export default class DynamicElement extends Node { + type: 'DynamicElement'; + name: string; + tag: Expression; + attributes: Attribute[] = []; + actions: Action[] = []; + bindings: Binding[] = []; + classes: Class[] = []; + handlers: EventHandler[] = []; + lets: Let[] = []; + intro?: Transition = null; + outro?: Transition = null; + animation?: Animation = null; + children: INode[]; + scope: TemplateScope; + needs_manual_style_scoping: boolean; + + constructor(component: Component, parent, scope, info) { + super(component, parent, scope, info); + + this.name = info.name; + + if (typeof info.tag === 'string') { + this.tag = new Expression(component, this, scope, string_literal(info.tag) as Literal); + } else { + this.tag = new Expression(component, this, scope, info.tag); + } + + info.attributes.forEach((node) => { + switch (node.type) { + case 'Action': + this.actions.push(new Action(component, this, scope, node)); + break; + + case 'Attribute': + case 'Spread': + this.attributes.push(new Attribute(component, this, scope, node)); + break; + + case 'Binding': + this.bindings.push(new Binding(component, this, scope, node)); + break; + + case 'Class': + this.classes.push(new Class(component, this, scope, node)); + break; + + case 'EventHandler': + this.handlers.push(new EventHandler(component, this, scope, node)); + break; + + case 'Let': { + const l = new Let(component, this, scope, node); + this.lets.push(l); + const dependencies = new Set([l.name.name]); + + l.names.forEach((name) => { + scope.add(name, dependencies, this); + }); + break; + } + + case 'Transition': { + const transition = new Transition(component, this, scope, node); + if (node.intro) this.intro = transition; + if (node.outro) this.outro = transition; + break; + } + + case 'Animation': + this.animation = new Animation(component, this, scope, node); + break; + + default: + throw new Error(`Not implemented: ${node.type}`); + } + }); + + this.scope = scope; + + this.children = map_children(component, this, this.scope, info.children); + + this.validate(); + + // TODO create BaseElement class or an interface which both DynamicElement and Element use + // to resolve the hacky cast + component.apply_stylesheet(this as any); + } + + validate() { + this.bindings.forEach(binding => { + if (binding.name !== 'this') { + this.component.error(binding, { + code: 'invalid-binding', + message: `'${binding.name}' is not a valid binding. svelte:element only supports bind:this` + }); + } + }); + } + + add_css_class() { + if (this.attributes.some(attr => attr.is_spread)) { + this.needs_manual_style_scoping = true; + return; + } + + const { id } = this.component.stylesheet; + + const class_attribute = this.attributes.find(a => a.name === 'class'); + + if (class_attribute && !class_attribute.is_true) { + if (class_attribute.chunks.length === 1 && class_attribute.chunks[0].type === 'Text') { + (class_attribute.chunks[0] as Text).data += ` ${id}`; + } else { + (class_attribute.chunks as Node[]).push( + new Text(this.component, this, this.scope, { + type: 'Text', + data: ` ${id}`, + synthetic: true + } as any) + ); + } + } else { + this.attributes.push( + new Attribute(this.component, this, this.scope, { + type: 'Attribute', + name: 'class', + value: [{ type: 'Text', data: id, synthetic: true }] + } as any) + ); + } + } +} diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 837a0296d097..74c70d2385d6 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -17,6 +17,7 @@ import Let from './Let'; import TemplateScope from './shared/TemplateScope'; import { INode } from './interfaces'; import Component from '../Component'; +import Expression from './shared/Expression'; import compiler_warnings from '../compiler_warnings'; import compiler_errors from '../compiler_errors'; @@ -131,6 +132,7 @@ export default class Element extends Node { children: INode[]; namespace: string; needs_manual_style_scoping: boolean; + dynamic_tag?: Expression; constructor(component: Component, parent: Node, scope: TemplateScope, info: any) { super(component, parent, scope, info); diff --git a/src/compiler/compile/nodes/Transition.ts b/src/compiler/compile/nodes/Transition.ts index 78799ac7a99f..08edb9771c8f 100644 --- a/src/compiler/compile/nodes/Transition.ts +++ b/src/compiler/compile/nodes/Transition.ts @@ -4,6 +4,7 @@ import Component from '../Component'; import TemplateScope from './shared/TemplateScope'; import { TemplateNode } from '../../interfaces'; import Element from './Element'; +import DynamicElement from './DynamicElement'; import compiler_errors from '../compiler_errors'; export default class Transition extends Node { @@ -13,7 +14,7 @@ export default class Transition extends Node { expression: Expression; is_local: boolean; - constructor(component: Component, parent: Element, scope: TemplateScope, info: TemplateNode) { + constructor(component: Component, parent: Element | DynamicElement, scope: TemplateScope, info: TemplateNode) { super(component, parent, scope, info); component.warn_if_undefined(info.name, info, scope); diff --git a/src/compiler/compile/nodes/interfaces.ts b/src/compiler/compile/nodes/interfaces.ts index a98c21511fb7..e2d406b3cf6d 100644 --- a/src/compiler/compile/nodes/interfaces.ts +++ b/src/compiler/compile/nodes/interfaces.ts @@ -32,6 +32,7 @@ import ThenBlock from './ThenBlock'; import Title from './Title'; import Transition from './Transition'; import Window from './Window'; +import DynamicElement from './DynamicElement'; // note: to write less types each of types in union below should have type defined as literal // https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#discriminating-unions @@ -45,6 +46,7 @@ export type INode = Action | Class | Comment | DebugTag +| DynamicElement | EachBlock | Element | ElseBlock diff --git a/src/compiler/compile/nodes/shared/map_children.ts b/src/compiler/compile/nodes/shared/map_children.ts index b1d0816aacbe..42edbe1ef3cf 100644 --- a/src/compiler/compile/nodes/shared/map_children.ts +++ b/src/compiler/compile/nodes/shared/map_children.ts @@ -1,6 +1,7 @@ import AwaitBlock from '../AwaitBlock'; import Body from '../Body'; import Comment from '../Comment'; +import DynamicElement from '../DynamicElement'; import EachBlock from '../EachBlock'; import Element from '../Element'; import Head from '../Head'; @@ -25,6 +26,7 @@ function get_constructor(type) { case 'AwaitBlock': return AwaitBlock; case 'Body': return Body; case 'Comment': return Comment; + case 'DynamicElement' : return DynamicElement; case 'EachBlock': return EachBlock; case 'Element': return Element; case 'Head': return Head; diff --git a/src/compiler/compile/render_dom/wrappers/DynamicElement.ts b/src/compiler/compile/render_dom/wrappers/DynamicElement.ts new file mode 100644 index 000000000000..62967b282792 --- /dev/null +++ b/src/compiler/compile/render_dom/wrappers/DynamicElement.ts @@ -0,0 +1,154 @@ +import Wrapper from './shared/Wrapper'; +import Renderer from '../Renderer'; +import Block from '../Block'; +import { b, x } from 'code-red'; +import { Identifier } from 'estree'; +import DynamicElement from '../../nodes/DynamicElement'; +import ElementWrapper from './Element/index'; +import create_debugging_comment from './shared/create_debugging_comment'; +import Element from '../../nodes/Element'; + +export default class DynamicElementWrapper extends Wrapper { + node: DynamicElement; + elementWrapper: ElementWrapper; + block: Block; + dependencies: string[]; + var: Identifier = { type: 'Identifier', name: 'dynamic_element' }; + + constructor( + renderer: Renderer, + block: Block, + parent: Wrapper, + node: DynamicElement, + strip_whitespace: boolean, + next_sibling: Wrapper + ) { + super(renderer, block, parent, node); + + this.not_static_content(); + this.dependencies = node.tag.dynamic_dependencies(); + + if (this.dependencies.length) { + block = block.child({ + comment: create_debugging_comment(node, renderer.component), + name: renderer.component.get_unique_name('dynamic_element_block'), + type: 'dynamic_element' + }); + renderer.blocks.push(block); + } + + (node as unknown as Element).dynamic_tag = node.tag; + + this.block = block; + this.elementWrapper = new ElementWrapper( + renderer, + this.block, + parent, + (node as unknown) as Element, + strip_whitespace, + next_sibling + ); + } + + render(block: Block, parent_node: Identifier, parent_nodes: Identifier) { + if (this.dependencies.length === 0) { + this.render_static_tag(block, parent_node, parent_nodes); + } else { + this.render_dynamic_tag(block, parent_node, parent_nodes); + } + } + + render_static_tag( + _block: Block, + parent_node: Identifier, + parent_nodes: Identifier + ) { + this.elementWrapper.render(this.block, parent_node, parent_nodes); + } + + render_dynamic_tag( + block: Block, + parent_node: Identifier, + parent_nodes: Identifier + ) { + this.elementWrapper.render( + this.block, + null, + (x`#nodes` as unknown) as Identifier + ); + + const has_transitions = !!( + this.block.has_intro_method || this.block.has_outro_method + ); + const dynamic = this.block.has_update_method; + + const previous_tag = block.get_unique_name('previous_tag'); + const snippet = this.node.tag.manipulate(block); + block.add_variable(previous_tag, snippet); + + const not_equal = this.renderer.component.component_options.immutable + ? x`@not_equal` + : x`@safe_not_equal`; + const condition = x`${this.renderer.dirty( + this.dependencies + )} && ${not_equal}(${previous_tag}, ${previous_tag} = ${snippet})`; + + block.chunks.init.push(b` + let ${this.var} = ${this.block.name}(#ctx); + `); + + block.chunks.create.push(b`${this.var}.c();`); + + if (this.renderer.options.hydratable) { + block.chunks.claim.push(b`${this.var}.l(${parent_nodes});`); + } + + block.chunks.mount.push( + b`${this.var}.m(${parent_node || '#target'}, ${ + parent_node ? 'null' : '#anchor' + });` + ); + + const anchor = this.get_or_create_anchor(block, parent_node, parent_nodes); + + if (has_transitions) { + block.chunks.intro.push(b`@transition_in(${this.var})`); + block.chunks.outro.push(b`@transition_out(${this.var})`); + + const body = b` + @group_outros(); + @transition_out(${this.var}, 1, 1, @noop); + @check_outros(); + ${this.var} = ${this.block.name}(#ctx); + ${this.var}.c(); + @transition_in(${this.var}); + ${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor}); + `; + + if (dynamic) { + block.chunks.update.push(b` + if (${condition}) { + ${body} + } else { + ${this.var}.p(#ctx, #dirty); + } + `); + } else { + block.chunks.update.push(b` + if (${condition}) { + ${body} + } + `); + } + } else if (dynamic) { + block.chunks.update.push(b` + ${this.var}.p(#ctx, #dirty); + if (${condition}) { + ${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor}); + } + `); + } + + block.chunks.destroy.push(b`${this.var}.d(detaching)`); + } +} diff --git a/src/compiler/compile/render_dom/wrappers/Element/index.ts b/src/compiler/compile/render_dom/wrappers/Element/index.ts index 9ec36b12d7da..0d3570b05022 100644 --- a/src/compiler/compile/render_dom/wrappers/Element/index.ts +++ b/src/compiler/compile/render_dom/wrappers/Element/index.ts @@ -211,6 +211,10 @@ export default class ElementWrapper extends Wrapper { } }); + if (node.dynamic_tag) { + block.add_dependencies(node.dynamic_tag.dependencies); + } + if (this.parent) { if (node.actions.length > 0 || node.animation || @@ -244,10 +248,18 @@ export default class ElementWrapper extends Wrapper { b`${node} = ${render_statement};` ); + if (this.node.dynamic_tag && this.renderer.options.dev) { + block.chunks.create.push(b`@validate_dynamic_element(${this.node.dynamic_tag.manipulate(block)});`); + + if (renderer.options.hydratable) { + block.chunks.claim.push(b`@validate_dynamic_element(${this.node.dynamic_tag.manipulate(block)});`); + } + } + if (renderer.options.hydratable) { if (parent_nodes) { block.chunks.claim.push(b` - ${node} = ${this.get_claim_statement(parent_nodes)}; + ${node} = ${this.get_claim_statement(block, parent_nodes)}; `); if (!this.void && this.node.children.length > 0) { @@ -286,13 +298,14 @@ export default class ElementWrapper extends Wrapper { block.chunks.destroy.push(b`if (detaching) @detach(${node});`); } + let staticChildren = null; + // insert static children with textContent or innerHTML const can_use_textcontent = this.can_use_textcontent(); if (!this.node.namespace && (this.can_use_innerhtml || can_use_textcontent) && this.fragment.nodes.length > 0) { if (this.fragment.nodes.length === 1 && this.fragment.nodes[0].node.type === 'Text') { - block.chunks.create.push( - b`${node}.textContent = ${string_literal((this.fragment.nodes[0] as TextWrapper).data)};` - ); + staticChildren = b`${node}.textContent = ${string_literal((this.fragment.nodes[0] as TextWrapper).data)};`; + block.chunks.create.push(staticChildren); } else { const state = { quasi: { @@ -311,9 +324,8 @@ export default class ElementWrapper extends Wrapper { to_html((this.fragment.nodes as unknown as Array), block, literal, state, can_use_raw_text); literal.quasis.push(state.quasi); - block.chunks.create.push( - b`${node}.${this.can_use_innerhtml ? 'innerHTML' : 'textContent'} = ${literal};` - ); + staticChildren = b`${node}.${this.can_use_innerhtml ? 'innerHTML' : 'textContent'} = ${literal};`; + block.chunks.create.push(staticChildren); } } else { this.fragment.nodes.forEach((child: Wrapper) => { @@ -342,6 +354,23 @@ export default class ElementWrapper extends Wrapper { this.add_classes(block); this.add_manual_style_scoping(block); + if (this.node.dynamic_tag) { + const dependencies = this.node.dynamic_tag.dynamic_dependencies(); + if (dependencies.length) { + const condition = block.renderer.dirty( + dependencies + ); + + block.chunks.update.push(b` + if (${condition}) { + @detach(${node}); + ${node} = ${render_statement}; + ${staticChildren} + } + `); + } + } + if (nodes && this.renderer.options.hydratable && !this.void) { block.chunks.claim.push( b`${this.node.children.length > 0 ? nodes : children}.forEach(@detach);` @@ -376,10 +405,11 @@ export default class ElementWrapper extends Wrapper { return x`@element_is("${name}", ${is.render_chunks(block).reduce((lhs, rhs) => x`${lhs} + ${rhs}`)})`; } - return x`@element("${name}")`; + const reference = this.node.dynamic_tag ? this.node.dynamic_tag.manipulate(block) : `"${name}"`; + return x`@element(${reference})`; } - get_claim_statement(nodes: Identifier) { + get_claim_statement(block: Block, nodes: Identifier) { const attributes = this.attributes .filter((attr) => !(attr instanceof SpreadAttributeWrapper) && !attr.property_name) .map((attr) => p`${(attr as StyleAttributeWrapper | AttributeWrapper).name}: true`); @@ -387,11 +417,12 @@ export default class ElementWrapper extends Wrapper { const name = this.node.namespace ? this.node.name : this.node.name.toUpperCase(); + const reference = this.node.dynamic_tag ? this.node.dynamic_tag.manipulate(block) : `"${name}"`; if (this.node.namespace === namespaces.svg) { - return x`@claim_svg_element(${nodes}, "${name}", { ${attributes} })`; + return x`@claim_svg_element(${nodes}, ${reference}, { ${attributes} })`; } else { - return x`@claim_element(${nodes}, "${name}", { ${attributes} })`; + return x`@claim_element(${nodes}, ${reference}, { ${attributes} })`; } } diff --git a/src/compiler/compile/render_dom/wrappers/Fragment.ts b/src/compiler/compile/render_dom/wrappers/Fragment.ts index 98805b9639b4..519ed2adc843 100644 --- a/src/compiler/compile/render_dom/wrappers/Fragment.ts +++ b/src/compiler/compile/render_dom/wrappers/Fragment.ts @@ -2,6 +2,7 @@ import Wrapper from './shared/Wrapper'; import AwaitBlock from './AwaitBlock'; import Body from './Body'; import DebugTag from './DebugTag'; +import DynamicElement from './DynamicElement'; import EachBlock from './EachBlock'; import Element from './Element/index'; import Head from './Head'; @@ -27,6 +28,7 @@ const wrappers = { Body, Comment: null, DebugTag, + DynamicElement, EachBlock, Element, Head, diff --git a/src/compiler/compile/render_ssr/Renderer.ts b/src/compiler/compile/render_ssr/Renderer.ts index 64e9ee1f4e81..b013fdd98dd5 100644 --- a/src/compiler/compile/render_ssr/Renderer.ts +++ b/src/compiler/compile/render_ssr/Renderer.ts @@ -1,6 +1,7 @@ import AwaitBlock from './handlers/AwaitBlock'; import Comment from './handlers/Comment'; import DebugTag from './handlers/DebugTag'; +import DynamicElement from './handlers/DynamicElement'; import EachBlock from './handlers/EachBlock'; import Element from './handlers/Element'; import Head from './handlers/Head'; @@ -27,6 +28,7 @@ const handlers: Record = { Body: noop, Comment, DebugTag, + DynamicElement, EachBlock, Element, Head, diff --git a/src/compiler/compile/render_ssr/handlers/DynamicElement.ts b/src/compiler/compile/render_ssr/handlers/DynamicElement.ts new file mode 100644 index 000000000000..9f701e29ab0d --- /dev/null +++ b/src/compiler/compile/render_ssr/handlers/DynamicElement.ts @@ -0,0 +1,215 @@ +import { + get_attribute_value, + get_class_attribute_value +} from './shared/get_attribute_value'; +import { get_slot_scope } from './shared/get_slot_scope'; +import { boolean_attributes } from './shared/boolean_attributes'; +import Renderer, { RenderOptions } from '../Renderer'; +import DynamicElement from '../../nodes/DynamicElement'; +import ElementHandler from './Element'; +import { x } from 'code-red'; +import Expression from '../../nodes/shared/Expression'; +import remove_whitespace_children from './utils/remove_whitespace_children'; +import Element from '../../nodes/Element'; +import { Expression as ESExpression } from 'estree'; + +export default function ( + node: DynamicElement, + renderer: Renderer, + options: RenderOptions & { + slot_scopes: Map; + } +) { + const dependencies = node.tag.dynamic_dependencies(); + + if (dependencies.length === 0) { + ((node as unknown) as Element).dynamic_tag = node.tag; + ElementHandler((node as unknown) as Element, renderer, options); + } else { + const children = remove_whitespace_children(node.children, node.next); + + // awkward special case + let node_contents; + + const contenteditable = node.attributes.some( + (attribute) => attribute.name === 'contenteditable' + ); + + const slot = node.get_static_attribute_value('slot'); + const nearest_inline_component = node.find_nearest(/InlineComponent/); + + if (slot && nearest_inline_component) { + renderer.push(); + } + + renderer.add_string('<'); + renderer.add_expression(node.tag.node as ESExpression); + + const class_expression_list = node.classes.map((class_directive) => { + const { expression, name } = class_directive; + const snippet = expression ? expression.node : x`#ctx.${name}`; // TODO is this right? + return x`${snippet} ? "${name}" : ""`; + }); + if (node.needs_manual_style_scoping) { + class_expression_list.push(x`"${node.component.stylesheet.id}"`); + } + const class_expression = + class_expression_list.length > 0 && + class_expression_list.reduce((lhs, rhs) => x`${lhs} + ' ' + ${rhs}`); + + if (node.attributes.some((attr) => attr.is_spread)) { + // TODO dry this out + const args = []; + node.attributes.forEach((attribute) => { + if (attribute.is_spread) { + args.push(attribute.expression.node); + } else { + const name = attribute.name.toLowerCase(); + if (attribute.is_true) { + args.push(x`{ ${attribute.name}: true }`); + } else if ( + boolean_attributes.has(name) && + attribute.chunks.length === 1 && + attribute.chunks[0].type !== 'Text' + ) { + // a boolean attribute with one non-Text chunk + args.push( + x`{ ${attribute.name}: ${ + (attribute.chunks[0] as Expression).node + } || null }` + ); + } else { + args.push( + x`{ ${attribute.name}: ${get_attribute_value(attribute)} }` + ); + } + } + }); + + renderer.add_expression(x`@spread([${args}], ${class_expression})`); + } else { + let add_class_attribute = !!class_expression; + node.attributes.forEach((attribute) => { + const name = attribute.name.toLowerCase(); + if (attribute.is_true) { + renderer.add_string(` ${attribute.name}`); + } else if ( + boolean_attributes.has(name) && + attribute.chunks.length === 1 && + attribute.chunks[0].type !== 'Text' + ) { + // a boolean attribute with one non-Text chunk + renderer.add_string(' '); + renderer.add_expression( + x`${(attribute.chunks[0] as Expression).node} ? "${ + attribute.name + }" : ""` + ); + } else if (name === 'class' && class_expression) { + add_class_attribute = false; + renderer.add_string(` ${attribute.name}="`); + renderer.add_expression( + x`[${get_class_attribute_value( + attribute + )}, ${class_expression}].join(' ').trim()` + ); + renderer.add_string('"'); + } else if ( + attribute.chunks.length === 1 && + attribute.chunks[0].type !== 'Text' + ) { + const snippet = (attribute.chunks[0] as Expression).node; + renderer.add_expression( + x`@add_attribute("${attribute.name}", ${snippet}, ${ + boolean_attributes.has(name) ? 1 : 0 + })` + ); + } else { + renderer.add_string(` ${attribute.name}="`); + renderer.add_expression( + (name === 'class' + ? get_class_attribute_value + : get_attribute_value)(attribute) + ); + renderer.add_string('"'); + } + }); + if (add_class_attribute) { + renderer.add_expression( + x`@add_classes([${class_expression}].join(' ').trim())` + ); + } + } + + node.bindings.forEach((binding) => { + const { name, expression } = binding; + + if (binding.is_readonly) { + return; + } + + if (name === 'group') { + // TODO server-render group bindings + } else if ( + contenteditable && + (name === 'textContent' || name === 'innerHTML') + ) { + node_contents = expression.node; + + // TODO where was this used? + // value = name === 'textContent' ? x`@escape($$value)` : x`$$value`; + } else { + const snippet = expression.node; + renderer.add_expression(x`@add_attribute("${name}", ${snippet}, 1)`); + } + }); + + if (options.hydratable && options.head_id) { + renderer.add_string(` data-svelte="${options.head_id}"`); + } + + renderer.add_string('>'); + + if (node_contents !== undefined) { + if (contenteditable) { + renderer.push(); + renderer.render(children, options); + const result = renderer.pop(); + + renderer.add_expression( + x`($$value => $$value === void 0 ? ${result} : $$value)(${node_contents})` + ); + } else { + renderer.add_expression(node_contents); + } + + renderer.add_string(''); + } else if (slot && nearest_inline_component) { + renderer.render(children, options); + + renderer.add_string(''); + + const lets = node.lets; + const seen = new Set(lets.map((l) => l.name.name)); + + nearest_inline_component.lets.forEach((l) => { + if (!seen.has(l.name.name)) lets.push(l); + }); + + options.slot_scopes.set(slot, { + input: get_slot_scope(node.lets), + output: renderer.pop() + }); + } else { + renderer.render(children, options); + + renderer.add_string(''); + } + } +} diff --git a/src/compiler/compile/render_ssr/handlers/Element.ts b/src/compiler/compile/render_ssr/handlers/Element.ts index c0bf826a8ecc..10835eaa88e3 100644 --- a/src/compiler/compile/render_ssr/handlers/Element.ts +++ b/src/compiler/compile/render_ssr/handlers/Element.ts @@ -6,6 +6,7 @@ import Element from '../../nodes/Element'; import { x } from 'code-red'; import Expression from '../../nodes/shared/Expression'; import remove_whitespace_children from './utils/remove_whitespace_children'; +import { Expression as ESExpression } from 'estree'; import fix_attribute_casing from '../../render_dom/wrappers/Element/fix_attribute_casing'; import { namespaces } from '../../../utils/namespaces'; @@ -22,7 +23,12 @@ export default function(node: Element, renderer: Renderer, options: RenderOption node.attributes.some((attribute) => attribute.name === 'contenteditable') ); - renderer.add_string(`<${node.name}`); + if (node.dynamic_tag) { + renderer.add_string('<'); + renderer.add_expression(node.dynamic_tag.node as ESExpression); + } else { + renderer.add_string(`<${node.name}`); + } const class_expression_list = node.classes.map(class_directive => { const { expression, name } = class_directive; @@ -152,13 +158,25 @@ export default function(node: Element, renderer: Renderer, options: RenderOption } if (!is_void(node.name)) { - renderer.add_string(``); + if (node.dynamic_tag) { + renderer.add_string(''); + } else { + renderer.add_string(``); + } } } else { renderer.render(children, options); if (!is_void(node.name)) { - renderer.add_string(``); + if (node.dynamic_tag) { + renderer.add_string(''); + } else { + renderer.add_string(``); + } } } } diff --git a/src/compiler/parse/errors.ts b/src/compiler/parse/errors.ts index ef1f72a8be84..63bd5b09199f 100644 --- a/src/compiler/parse/errors.ts +++ b/src/compiler/parse/errors.ts @@ -99,6 +99,10 @@ export default { code: `invalid-${slug}-content`, message: `<${name}> cannot have children` }), + invalid_element_definition: { + code: 'invalid-element-definition', + message: 'Invalid element definition' + }, invalid_element_placement: (slug: string, name: string) => ({ code: `invalid-${slug}-placement`, message: `<${name}> tags cannot be inside elements or blocks` @@ -161,6 +165,10 @@ export default { code: 'missing-attribute-value', message: 'Expected value for the attribute' }, + missing_element_definition: { + code: 'missing-element-definition', + message: ' must have a \'this\' attribute' + }, unclosed_script: { code: 'unclosed-script', message: ' + + diff --git a/test/runtime/samples/dynamic-element-binding-invalid/_config.js b/test/runtime/samples/dynamic-element-binding-invalid/_config.js new file mode 100644 index 000000000000..33a48a0e853e --- /dev/null +++ b/test/runtime/samples/dynamic-element-binding-invalid/_config.js @@ -0,0 +1,3 @@ +export default { + error: "'value' is not a valid binding. svelte:element only supports bind:this" +}; diff --git a/test/runtime/samples/dynamic-element-binding-invalid/main.svelte b/test/runtime/samples/dynamic-element-binding-invalid/main.svelte new file mode 100644 index 000000000000..45f8f9606141 --- /dev/null +++ b/test/runtime/samples/dynamic-element-binding-invalid/main.svelte @@ -0,0 +1,6 @@ + + + diff --git a/test/runtime/samples/dynamic-element-binding-this/_config.js b/test/runtime/samples/dynamic-element-binding-this/_config.js new file mode 100644 index 000000000000..e0722c937573 --- /dev/null +++ b/test/runtime/samples/dynamic-element-binding-this/_config.js @@ -0,0 +1,8 @@ +export default { + html: '
', + + test({ assert, component, target }) { + const div = target.querySelector('div'); + assert.equal(div, component.foo); + } +}; diff --git a/test/runtime/samples/dynamic-element-binding-this/main.svelte b/test/runtime/samples/dynamic-element-binding-this/main.svelte new file mode 100644 index 000000000000..75e8b02ce161 --- /dev/null +++ b/test/runtime/samples/dynamic-element-binding-this/main.svelte @@ -0,0 +1,6 @@ + + + diff --git a/test/runtime/samples/dynamic-element-change-tag/_config.js b/test/runtime/samples/dynamic-element-change-tag/_config.js new file mode 100644 index 000000000000..9e4bf6fd32ad --- /dev/null +++ b/test/runtime/samples/dynamic-element-change-tag/_config.js @@ -0,0 +1,17 @@ +export default { + props: { + tag: 'div' + }, + html: '
Foo
', + + test({ assert, component, target }) { + component.tag = 'h1'; + + assert.htmlEqual( + target.innerHTML, + ` +

Foo

+ ` + ); + } +}; diff --git a/test/runtime/samples/dynamic-element-change-tag/main.svelte b/test/runtime/samples/dynamic-element-change-tag/main.svelte new file mode 100644 index 000000000000..a9c4d5c00c74 --- /dev/null +++ b/test/runtime/samples/dynamic-element-change-tag/main.svelte @@ -0,0 +1,5 @@ + + +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-event-handler/_config.js b/test/runtime/samples/dynamic-element-event-handler/_config.js new file mode 100644 index 000000000000..03b8f7879d44 --- /dev/null +++ b/test/runtime/samples/dynamic-element-event-handler/_config.js @@ -0,0 +1,21 @@ +let clicked = false; +function handler() { + clicked = true; +} + +export default { + props: { + handler + }, + html: '', + + test({ assert, target }) { + assert.equal(clicked, false); + + const button = target.querySelector('button'); + const click = new window.MouseEvent('click'); + button.dispatchEvent(click); + + assert.equal(clicked, true); + } +}; diff --git a/test/runtime/samples/dynamic-element-event-handler/main.svelte b/test/runtime/samples/dynamic-element-event-handler/main.svelte new file mode 100644 index 000000000000..7a7fef9c22cf --- /dev/null +++ b/test/runtime/samples/dynamic-element-event-handler/main.svelte @@ -0,0 +1,6 @@ + + +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-expression/_config.js b/test/runtime/samples/dynamic-element-expression/_config.js new file mode 100644 index 000000000000..acad91c9015a --- /dev/null +++ b/test/runtime/samples/dynamic-element-expression/_config.js @@ -0,0 +1,3 @@ +export default { + html: '
Foo
' +}; diff --git a/test/runtime/samples/dynamic-element-expression/main.svelte b/test/runtime/samples/dynamic-element-expression/main.svelte new file mode 100644 index 000000000000..7ec11e4ef6d8 --- /dev/null +++ b/test/runtime/samples/dynamic-element-expression/main.svelte @@ -0,0 +1 @@ +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-pass-props/_config.js b/test/runtime/samples/dynamic-element-pass-props/_config.js new file mode 100644 index 000000000000..35f8b7abdf49 --- /dev/null +++ b/test/runtime/samples/dynamic-element-pass-props/_config.js @@ -0,0 +1,16 @@ +let clicked = false; + +export default { + props: { + tag: 'div', + onClick: () => clicked = true + }, + html: '
Foo
', + + async test({ assert, target, window }) { + const div = target.querySelector('div'); + await div.dispatchEvent(new window.MouseEvent('click')); + + assert.equal(clicked, true); + } +}; diff --git a/test/runtime/samples/dynamic-element-pass-props/main.svelte b/test/runtime/samples/dynamic-element-pass-props/main.svelte new file mode 100644 index 000000000000..6a54a93f2718 --- /dev/null +++ b/test/runtime/samples/dynamic-element-pass-props/main.svelte @@ -0,0 +1,6 @@ + + +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-reuse-children/_config.js b/test/runtime/samples/dynamic-element-reuse-children/_config.js new file mode 100644 index 000000000000..3974ee5876f9 --- /dev/null +++ b/test/runtime/samples/dynamic-element-reuse-children/_config.js @@ -0,0 +1,22 @@ +export default { + props: { + tag: 'div', + text: 'Foo' + }, + html: '
Foo
', + + test({ assert, component, target }) { + const innerDiv = target.querySelector('div > div'); + component.tag = 'h1'; + component.text = 'Bar'; + + assert.htmlEqual( + target.innerHTML, + ` +

Bar

+ ` + ); + + assert.equal(innerDiv, target.querySelector('h1 > div')); + } +}; diff --git a/test/runtime/samples/dynamic-element-reuse-children/main.svelte b/test/runtime/samples/dynamic-element-reuse-children/main.svelte new file mode 100644 index 000000000000..4be99aab09a6 --- /dev/null +++ b/test/runtime/samples/dynamic-element-reuse-children/main.svelte @@ -0,0 +1,9 @@ + + + +
+ {text} +
+
\ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-store/_config.js b/test/runtime/samples/dynamic-element-store/_config.js new file mode 100644 index 000000000000..ded19eef7954 --- /dev/null +++ b/test/runtime/samples/dynamic-element-store/_config.js @@ -0,0 +1,3 @@ +export default { + html: '
' +}; diff --git a/test/runtime/samples/dynamic-element-store/main.svelte b/test/runtime/samples/dynamic-element-store/main.svelte new file mode 100644 index 000000000000..84a577ecee38 --- /dev/null +++ b/test/runtime/samples/dynamic-element-store/main.svelte @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-string/_config.js b/test/runtime/samples/dynamic-element-string/_config.js new file mode 100644 index 000000000000..acad91c9015a --- /dev/null +++ b/test/runtime/samples/dynamic-element-string/_config.js @@ -0,0 +1,3 @@ +export default { + html: '
Foo
' +}; diff --git a/test/runtime/samples/dynamic-element-string/main.svelte b/test/runtime/samples/dynamic-element-string/main.svelte new file mode 100644 index 000000000000..62d65d5f203c --- /dev/null +++ b/test/runtime/samples/dynamic-element-string/main.svelte @@ -0,0 +1 @@ +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-variable/_config.js b/test/runtime/samples/dynamic-element-variable/_config.js new file mode 100644 index 000000000000..20e0fa94184d --- /dev/null +++ b/test/runtime/samples/dynamic-element-variable/_config.js @@ -0,0 +1,20 @@ +export default { + props: { + tag: 'div', + text: 'Foo' + }, + html: '
Foo
', + + test({ assert, component, target }) { + const div = target.firstChild; + component.tag = 'nav'; + component.text = 'Bar'; + + assert.htmlEqual(target.innerHTML, ` + + `); + + const h1 = target.firstChild; + assert.notEqual(div, h1); + } +}; diff --git a/test/runtime/samples/dynamic-element-variable/main.svelte b/test/runtime/samples/dynamic-element-variable/main.svelte new file mode 100644 index 000000000000..d60953bba5de --- /dev/null +++ b/test/runtime/samples/dynamic-element-variable/main.svelte @@ -0,0 +1,6 @@ + + +{text} \ No newline at end of file diff --git a/test/server-side-rendering/samples/dynamic-element-string/_expected.html b/test/server-side-rendering/samples/dynamic-element-string/_expected.html new file mode 100644 index 000000000000..cb98432e1415 --- /dev/null +++ b/test/server-side-rendering/samples/dynamic-element-string/_expected.html @@ -0,0 +1 @@ +
Foo
diff --git a/test/server-side-rendering/samples/dynamic-element-string/main.svelte b/test/server-side-rendering/samples/dynamic-element-string/main.svelte new file mode 100644 index 000000000000..62d65d5f203c --- /dev/null +++ b/test/server-side-rendering/samples/dynamic-element-string/main.svelte @@ -0,0 +1 @@ +Foo \ No newline at end of file diff --git a/test/server-side-rendering/samples/dynamic-element-variable/_expected.html b/test/server-side-rendering/samples/dynamic-element-variable/_expected.html new file mode 100644 index 000000000000..3ae445f54c71 --- /dev/null +++ b/test/server-side-rendering/samples/dynamic-element-variable/_expected.html @@ -0,0 +1,2 @@ +

Foo

+
Bar
\ No newline at end of file diff --git a/test/server-side-rendering/samples/dynamic-element-variable/main.svelte b/test/server-side-rendering/samples/dynamic-element-variable/main.svelte new file mode 100644 index 000000000000..9aaba18bf12f --- /dev/null +++ b/test/server-side-rendering/samples/dynamic-element-variable/main.svelte @@ -0,0 +1,7 @@ + + +Foo +Bar \ No newline at end of file