From f001107711d072892133a3c4964b7eced900d9ca Mon Sep 17 00:00:00 2001 From: Alfred Ringstad Date: Fri, 25 Sep 2020 16:30:42 +0200 Subject: [PATCH 1/6] Implement svelte:element for dynamically setting HTML DOM type --- src/compiler/compile/nodes/Animation.ts | 3 +- src/compiler/compile/nodes/Binding.ts | 3 +- src/compiler/compile/nodes/DynamicElement.ts | 99 ++++++++ src/compiler/compile/nodes/Element.ts | 2 + src/compiler/compile/nodes/Transition.ts | 3 +- src/compiler/compile/nodes/interfaces.ts | 2 + .../compile/nodes/shared/map_children.ts | 2 + .../render_dom/wrappers/DynamicElement.ts | 154 +++++++++++++ .../render_dom/wrappers/Element/index.ts | 10 +- .../compile/render_dom/wrappers/Fragment.ts | 2 + src/compiler/compile/render_ssr/Renderer.ts | 2 + .../render_ssr/handlers/DynamicElement.ts | 215 ++++++++++++++++++ .../compile/render_ssr/handlers/Element.ts | 24 +- src/compiler/parse/state/tag.ts | 29 ++- .../dynamic-element-string/input.svelte | 1 + .../dynamic-element-string/output.json | 18 ++ .../dynamic-element-variable/input.svelte | 1 + .../dynamic-element-variable/output.json | 33 +++ .../error-svelte-selfdestructive/error.json | 2 +- .../_config.js | 22 ++ .../main.svelte | 9 + .../dynamic-element-change-tag/_config.js | 17 ++ .../dynamic-element-change-tag/main.svelte | 5 + .../dynamic-element-expression/_config.js | 3 + .../dynamic-element-expression/main.svelte | 1 + .../dynamic-element-pass-props/_config.js | 16 ++ .../dynamic-element-pass-props/main.svelte | 6 + .../samples/dynamic-element-string/_config.js | 3 + .../dynamic-element-string/main.svelte | 1 + .../dynamic-element-variable/_config.js | 20 ++ .../dynamic-element-variable/main.svelte | 6 + .../dynamic-element-string/_expected.html | 1 + .../dynamic-element-string/main.svelte | 1 + .../dynamic-element-variable/_expected.html | 2 + .../dynamic-element-variable/main.svelte | 7 + test/sourcemaps/samples/two-scripts/output.js | 47 ++++ 36 files changed, 758 insertions(+), 14 deletions(-) create mode 100644 src/compiler/compile/nodes/DynamicElement.ts create mode 100644 src/compiler/compile/render_dom/wrappers/DynamicElement.ts create mode 100644 src/compiler/compile/render_ssr/handlers/DynamicElement.ts create mode 100644 test/parser/samples/dynamic-element-string/input.svelte create mode 100644 test/parser/samples/dynamic-element-string/output.json create mode 100644 test/parser/samples/dynamic-element-variable/input.svelte create mode 100644 test/parser/samples/dynamic-element-variable/output.json create mode 100644 test/runtime/samples/dynamic-element-change-tag-reuse-children/_config.js create mode 100644 test/runtime/samples/dynamic-element-change-tag-reuse-children/main.svelte create mode 100644 test/runtime/samples/dynamic-element-change-tag/_config.js create mode 100644 test/runtime/samples/dynamic-element-change-tag/main.svelte create mode 100644 test/runtime/samples/dynamic-element-expression/_config.js create mode 100644 test/runtime/samples/dynamic-element-expression/main.svelte create mode 100644 test/runtime/samples/dynamic-element-pass-props/_config.js create mode 100644 test/runtime/samples/dynamic-element-pass-props/main.svelte create mode 100644 test/runtime/samples/dynamic-element-string/_config.js create mode 100644 test/runtime/samples/dynamic-element-string/main.svelte create mode 100644 test/runtime/samples/dynamic-element-variable/_config.js create mode 100644 test/runtime/samples/dynamic-element-variable/main.svelte create mode 100644 test/server-side-rendering/samples/dynamic-element-string/_expected.html create mode 100644 test/server-side-rendering/samples/dynamic-element-string/main.svelte create mode 100644 test/server-side-rendering/samples/dynamic-element-variable/_expected.html create mode 100644 test/server-side-rendering/samples/dynamic-element-variable/main.svelte create mode 100644 test/sourcemaps/samples/two-scripts/output.js diff --git a/src/compiler/compile/nodes/Animation.ts b/src/compiler/compile/nodes/Animation.ts index 0758a6c1374e..6ac8b0cf14c6 100644 --- a/src/compiler/compile/nodes/Animation.ts +++ b/src/compiler/compile/nodes/Animation.ts @@ -5,13 +5,14 @@ import TemplateScope from './shared/TemplateScope'; import { TemplateNode } from '../../interfaces'; import Element from './Element'; import EachBlock from './EachBlock'; +import DynamicElement from './DynamicElement'; export default class Animation extends Node { type: 'Animation'; 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 8fcc70ded9a0..ed74f276f270 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'; // TODO this should live in a specific binding const read_only_media_attributes = new Set([ @@ -31,7 +32,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..1b5348e03036 --- /dev/null +++ b/src/compiler/compile/nodes/DynamicElement.ts @@ -0,0 +1,99 @@ +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'; + +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; + + 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); + } +} diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 77cb2e062d73..caca40e12045 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'; const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|svg|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/; @@ -129,6 +130,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 27ee5d9df276..beb4661ff100 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'; export default class Transition extends Node { type: 'Transition'; @@ -12,7 +13,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..f71ff8f0fa93 --- /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 FragmentWrapper from './Fragment'; +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 { + fragment: FragmentWrapper; + 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); + const body = b` + ${ + has_transitions + ? b` + @group_outros(); + @transition_out(${this.var}, 1, 1, @noop); + @check_outros(); + ` + : b`${this.var}.d(1);` + } + ${this.var} = ${this.block.name}(#ctx); + ${this.var}.c(); + ${has_transitions && b`@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} + } + `); + } + + if (has_transitions) { + block.chunks.intro.push(b`@transition_in(${this.var})`); + block.chunks.outro.push(b`@transition_out(${this.var})`); + } + + 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 b21a1aa24b59..362c766221da 100644 --- a/src/compiler/compile/render_dom/wrappers/Element/index.ts +++ b/src/compiler/compile/render_dom/wrappers/Element/index.ts @@ -247,7 +247,7 @@ export default class ElementWrapper extends Wrapper { 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) { @@ -368,10 +368,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.node.attributes .filter((attr) => attr.type === 'Attribute') .map((attr) => p`${attr.name}: true`); @@ -382,7 +383,8 @@ export default class ElementWrapper extends Wrapper { const svg = this.node.namespace === namespaces.svg ? 1 : null; - return x`@claim_element(${nodes}, "${name}", { ${attributes} }, ${svg})`; + const reference = this.node.dynamic_tag ? this.node.dynamic_tag.manipulate(block) : `"${name}"`; + return x`@claim_element(${nodes}, ${reference}, { ${attributes} }, ${svg})`; } add_directives_in_order (block: Block) { 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..631ced09d427 --- /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 d10c16519876..4a84e52adca2 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'; export default function(node: Element, renderer: Renderer, options: RenderOptions) { @@ -20,7 +21,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; @@ -136,13 +142,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/state/tag.ts b/src/compiler/parse/state/tag.ts index 799124b2bdf7..a6934269353f 100644 --- a/src/compiler/parse/state/tag.ts +++ b/src/compiler/parse/state/tag.ts @@ -18,7 +18,7 @@ const meta_tags = new Map([ ['svelte:body', 'Body'] ]); -const valid_meta_tags = Array.from(meta_tags.keys()).concat('svelte:self', 'svelte:component', 'svelte:fragment'); +const valid_meta_tags = Array.from(meta_tags.keys()).concat('svelte:self', 'svelte:component', 'svelte:fragment', 'svelte:element'); const specials = new Map([ [ @@ -40,6 +40,7 @@ const specials = new Map([ const SELF = /^svelte:self(?=[\s/>])/; const COMPONENT = /^svelte:component(?=[\s/>])/; const SLOT = /^svelte:fragment(?=[\s/>])/; +const ELEMENT = /^svelte:element(?=[\s/>])/; function parent_is_head(stack) { let i = stack.length; @@ -109,8 +110,9 @@ export default function tag(parser: Parser) { ? meta_tags.get(name) : (/[A-Z]/.test(name[0]) || name === 'svelte:self' || name === 'svelte:component') ? 'InlineComponent' : name === 'svelte:fragment' ? 'SlotTemplate' - : name === 'title' && parent_is_head(parser.stack) ? 'Title' - : name === 'slot' && !parser.customElement ? 'Slot' : 'Element'; + : (name === 'svelte:element') ? 'DynamicElement' + : name === 'title' && parent_is_head(parser.stack) ? 'Title' + : name === 'slot' && !parser.customElement ? 'Slot' : 'Element'; const element: TemplateNode = { start, @@ -196,6 +198,26 @@ export default function tag(parser: Parser) { element.expression = definition.value[0].expression; } + + if (name === 'svelte:element') { + const index = element.attributes.findIndex(attr => attr.type === 'Attribute' && attr.name === 'tag'); + if (!~index) { + parser.error({ + code: 'missing-element-definition', + message: ' must have a \'tag\' attribute' + }, start); + } + + const definition = element.attributes.splice(index, 1)[0]; + if (definition.value === true || definition.value.length !== 1 || (definition.value[0].type !== 'Text' && definition.value[0].type !== 'MustacheTag' && definition.value[0].type !== 'AttributeShorthand')) { + parser.error({ + code: 'invalid-element-definition', + message: 'invalid element definition' + }, definition.start); + } + + element.tag = definition.value[0].data || definition.value[0].expression; + } // special cases – top-level + + +
+ {text} +
+
\ No newline at end of file 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..8e30ac99c53b --- /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-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..f12ee38b9786 --- /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..dbd4e79abd74 --- /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-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..2cf3b2b1895b --- /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..76dd0e606e6a --- /dev/null +++ b/test/runtime/samples/dynamic-element-variable/_config.js @@ -0,0 +1,20 @@ +export default { + props: { + tag: 'di', + text: 'Foo' + }, + html: '
Foo
', + + test({ assert, component, target }) { + const div = target.firstChild; + component.tag = 'na'; + 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..8e94f12e11d7 --- /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..2cf3b2b1895b --- /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..45addc257614 --- /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 diff --git a/test/sourcemaps/samples/two-scripts/output.js b/test/sourcemaps/samples/two-scripts/output.js new file mode 100644 index 000000000000..3d6fd51b85d5 --- /dev/null +++ b/test/sourcemaps/samples/two-scripts/output.js @@ -0,0 +1,47 @@ +/* test/sourcemaps/samples/two-scripts/input.svelte generated by Svelte vx.xx.x */ +import { + SvelteComponent, + detach, + init, + insert, + noop, + safe_not_equal, + text +} from "svelte/internal"; + +function create_fragment(ctx) { + let t_value = foo.bar.baz + ""; + let t; + + return { + c() { + t = text(t_value); + }, + m(target, anchor) { + insert(target, t, anchor); + }, + p: noop, + i: noop, + o: noop, + d(detaching) { + if (detaching) detach(t); + } + }; +} + +let first; + +function assertThisLine() { + +} + +class Input extends SvelteComponent { + constructor(options) { + super(); + init(this, options, null, create_fragment, safe_not_equal, {}); + } +} + +export default Input; +export { first }; +//# sourceMappingURL=output.js.map \ No newline at end of file From 4fcc68188d5c4d9098a195511d2cfc86da66090d Mon Sep 17 00:00:00 2001 From: Alfred Ringstad Date: Wed, 7 Oct 2020 09:26:46 +0200 Subject: [PATCH 2/6] Add add_css_class method to DynamicElement --- src/compiler/compile/nodes/DynamicElement.ts | 35 +++++++++++++++++++ .../render_ssr/handlers/DynamicElement.ts | 6 ++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/compiler/compile/nodes/DynamicElement.ts b/src/compiler/compile/nodes/DynamicElement.ts index 1b5348e03036..8ec96f6eafcf 100644 --- a/src/compiler/compile/nodes/DynamicElement.ts +++ b/src/compiler/compile/nodes/DynamicElement.ts @@ -14,6 +14,7 @@ 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'; @@ -30,6 +31,7 @@ export default class DynamicElement extends Node { animation?: Animation = null; children: INode[]; scope: TemplateScope; + needs_manual_style_scoping: boolean; constructor(component: Component, parent, scope, info) { super(component, parent, scope, info); @@ -96,4 +98,37 @@ export default class DynamicElement extends Node { this.children = map_children(component, this, this.scope, info.children); } + + 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/render_ssr/handlers/DynamicElement.ts b/src/compiler/compile/render_ssr/handlers/DynamicElement.ts index 631ced09d427..9f701e29ab0d 100644 --- a/src/compiler/compile/render_ssr/handlers/DynamicElement.ts +++ b/src/compiler/compile/render_ssr/handlers/DynamicElement.ts @@ -50,9 +50,9 @@ export default function ( 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}"`); - // } + 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}`); From 54e1c23b329bd67cb3b43e491dbf07ef35d85fd4 Mon Sep 17 00:00:00 2001 From: Alfred Ringstad Date: Wed, 31 Mar 2021 19:08:43 +0200 Subject: [PATCH 3/6] Reuse DOM elements if possible. Add dev warnings when passing nullish values as tag. Throw an error when trying to bind other than this. Add more tests. --- src/compiler/compile/nodes/DynamicElement.ts | 13 ++++ .../render_dom/wrappers/DynamicElement.ts | 60 +++++++++---------- .../render_dom/wrappers/Element/index.ts | 41 +++++++++++-- src/compiler/parse/state/tag.ts | 4 +- src/runtime/internal/dev.ts | 6 ++ .../_config.js | 9 +++ .../main.svelte | 5 ++ .../_config.js | 3 + .../main.svelte | 6 ++ .../dynamic-element-binding-this/_config.js | 8 +++ .../dynamic-element-binding-this/main.svelte | 6 ++ .../dynamic-element-event-handler/_config.js | 21 +++++++ .../dynamic-element-event-handler/main.svelte | 6 ++ .../_config.js | 0 .../main.svelte | 0 .../samples/dynamic-element-store/_config.js | 3 + .../samples/dynamic-element-store/main.svelte | 6 ++ .../dynamic-element-variable/_config.js | 4 +- .../dynamic-element-variable/main.svelte | 4 +- test/sourcemaps/samples/two-scripts/output.js | 47 --------------- 20 files changed, 163 insertions(+), 89 deletions(-) create mode 100644 test/runtime/samples/dev-warning-dynamic-element-nullish-tag/_config.js create mode 100644 test/runtime/samples/dev-warning-dynamic-element-nullish-tag/main.svelte create mode 100644 test/runtime/samples/dynamic-element-binding-invalid/_config.js create mode 100644 test/runtime/samples/dynamic-element-binding-invalid/main.svelte create mode 100644 test/runtime/samples/dynamic-element-binding-this/_config.js create mode 100644 test/runtime/samples/dynamic-element-binding-this/main.svelte create mode 100644 test/runtime/samples/dynamic-element-event-handler/_config.js create mode 100644 test/runtime/samples/dynamic-element-event-handler/main.svelte rename test/runtime/samples/{dynamic-element-change-tag-reuse-children => dynamic-element-reuse-children}/_config.js (100%) rename test/runtime/samples/{dynamic-element-change-tag-reuse-children => dynamic-element-reuse-children}/main.svelte (100%) create mode 100644 test/runtime/samples/dynamic-element-store/_config.js create mode 100644 test/runtime/samples/dynamic-element-store/main.svelte delete mode 100644 test/sourcemaps/samples/two-scripts/output.js diff --git a/src/compiler/compile/nodes/DynamicElement.ts b/src/compiler/compile/nodes/DynamicElement.ts index 8ec96f6eafcf..bb59ad44c9e4 100644 --- a/src/compiler/compile/nodes/DynamicElement.ts +++ b/src/compiler/compile/nodes/DynamicElement.ts @@ -97,6 +97,19 @@ export default class DynamicElement extends Node { this.scope = scope; this.children = map_children(component, this, this.scope, info.children); + + this.validate(); + } + + 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() { diff --git a/src/compiler/compile/render_dom/wrappers/DynamicElement.ts b/src/compiler/compile/render_dom/wrappers/DynamicElement.ts index f71ff8f0fa93..62967b282792 100644 --- a/src/compiler/compile/render_dom/wrappers/DynamicElement.ts +++ b/src/compiler/compile/render_dom/wrappers/DynamicElement.ts @@ -1,7 +1,6 @@ import Wrapper from './shared/Wrapper'; import Renderer from '../Renderer'; import Block from '../Block'; -import FragmentWrapper from './Fragment'; import { b, x } from 'code-red'; import { Identifier } from 'estree'; import DynamicElement from '../../nodes/DynamicElement'; @@ -10,7 +9,6 @@ import create_debugging_comment from './shared/create_debugging_comment'; import Element from '../../nodes/Element'; export default class DynamicElementWrapper extends Wrapper { - fragment: FragmentWrapper; node: DynamicElement; elementWrapper: ElementWrapper; block: Block; @@ -112,43 +110,45 @@ export default class DynamicElementWrapper extends Wrapper { ); const anchor = this.get_or_create_anchor(block, parent_node, parent_nodes); - const body = b` - ${ - has_transitions - ? b` - @group_outros(); - @transition_out(${this.var}, 1, 1, @noop); - @check_outros(); - ` - : b`${this.var}.d(1);` - } - ${this.var} = ${this.block.name}(#ctx); - ${this.var}.c(); - ${has_transitions && b`@transition_in(${this.var})`} - ${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor}); - `; - if (dynamic) { - block.chunks.update.push(b` - if (${condition}) { - ${body} + 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 { - ${this.var}.p(#ctx, #dirty); + block.chunks.update.push(b` + if (${condition}) { + ${body} + } + `); } - `); - } else { + } else if (dynamic) { block.chunks.update.push(b` + ${this.var}.p(#ctx, #dirty); if (${condition}) { - ${body} + ${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor}); } `); } - if (has_transitions) { - block.chunks.intro.push(b`@transition_in(${this.var})`); - block.chunks.outro.push(b`@transition_out(${this.var})`); - } - 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 362c766221da..c9ae8b9150d1 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,6 +248,14 @@ 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` @@ -278,13 +290,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: { @@ -303,9 +316,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) => { @@ -334,6 +346,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);` diff --git a/src/compiler/parse/state/tag.ts b/src/compiler/parse/state/tag.ts index a6934269353f..dada08b686ea 100644 --- a/src/compiler/parse/state/tag.ts +++ b/src/compiler/parse/state/tag.ts @@ -181,7 +181,7 @@ export default function tag(parser: Parser) { if (name === 'svelte:component') { const index = element.attributes.findIndex(attr => attr.type === 'Attribute' && attr.name === 'this'); - if (!~index) { + if (index === -1) { parser.error({ code: 'missing-component-definition', message: " must have a 'this' attribute" @@ -201,7 +201,7 @@ export default function tag(parser: Parser) { if (name === 'svelte:element') { const index = element.attributes.findIndex(attr => attr.type === 'Attribute' && attr.name === 'tag'); - if (!~index) { + if (index === -1) { parser.error({ code: 'missing-element-definition', message: ' must have a \'tag\' attribute' diff --git a/src/runtime/internal/dev.ts b/src/runtime/internal/dev.ts index 99ff067474dd..bfb3d795f2ad 100644 --- a/src/runtime/internal/dev.ts +++ b/src/runtime/internal/dev.ts @@ -97,6 +97,12 @@ export function validate_slots(name, slot, keys) { } } +export function validate_dynamic_element(tag) { + if (tag == null) { + console.warn(' expects a non-nullish value in attribute "tag"'); + } +} + type Props = Record; export interface SvelteComponentDev { $set(props?: Props): void; diff --git a/test/runtime/samples/dev-warning-dynamic-element-nullish-tag/_config.js b/test/runtime/samples/dev-warning-dynamic-element-nullish-tag/_config.js new file mode 100644 index 000000000000..026a93cf7422 --- /dev/null +++ b/test/runtime/samples/dev-warning-dynamic-element-nullish-tag/_config.js @@ -0,0 +1,9 @@ +export default { + compileOptions: { + dev: true + }, + + warnings: [ + ' expects a non-nullish value in attribute "tag"' + ] +}; diff --git a/test/runtime/samples/dev-warning-dynamic-element-nullish-tag/main.svelte b/test/runtime/samples/dev-warning-dynamic-element-nullish-tag/main.svelte new file mode 100644 index 000000000000..8ab231d49d00 --- /dev/null +++ b/test/runtime/samples/dev-warning-dynamic-element-nullish-tag/main.svelte @@ -0,0 +1,5 @@ + + + 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..4639427fdf82 --- /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..0c1e3a3f15cb --- /dev/null +++ b/test/runtime/samples/dynamic-element-binding-this/main.svelte @@ -0,0 +1,6 @@ + + + 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..1fd8c41dcf0b --- /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-change-tag-reuse-children/_config.js b/test/runtime/samples/dynamic-element-reuse-children/_config.js similarity index 100% rename from test/runtime/samples/dynamic-element-change-tag-reuse-children/_config.js rename to test/runtime/samples/dynamic-element-reuse-children/_config.js diff --git a/test/runtime/samples/dynamic-element-change-tag-reuse-children/main.svelte b/test/runtime/samples/dynamic-element-reuse-children/main.svelte similarity index 100% rename from test/runtime/samples/dynamic-element-change-tag-reuse-children/main.svelte rename to test/runtime/samples/dynamic-element-reuse-children/main.svelte 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..5b8098d24405 --- /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-variable/_config.js b/test/runtime/samples/dynamic-element-variable/_config.js index 76dd0e606e6a..20e0fa94184d 100644 --- a/test/runtime/samples/dynamic-element-variable/_config.js +++ b/test/runtime/samples/dynamic-element-variable/_config.js @@ -1,13 +1,13 @@ export default { props: { - tag: 'di', + tag: 'div', text: 'Foo' }, html: '
Foo
', test({ assert, component, target }) { const div = target.firstChild; - component.tag = 'na'; + component.tag = 'nav'; component.text = 'Bar'; assert.htmlEqual(target.innerHTML, ` diff --git a/test/runtime/samples/dynamic-element-variable/main.svelte b/test/runtime/samples/dynamic-element-variable/main.svelte index 8e94f12e11d7..4cac42263573 100644 --- a/test/runtime/samples/dynamic-element-variable/main.svelte +++ b/test/runtime/samples/dynamic-element-variable/main.svelte @@ -1,6 +1,6 @@ -{text} \ No newline at end of file +{text} \ No newline at end of file diff --git a/test/sourcemaps/samples/two-scripts/output.js b/test/sourcemaps/samples/two-scripts/output.js deleted file mode 100644 index 3d6fd51b85d5..000000000000 --- a/test/sourcemaps/samples/two-scripts/output.js +++ /dev/null @@ -1,47 +0,0 @@ -/* test/sourcemaps/samples/two-scripts/input.svelte generated by Svelte vx.xx.x */ -import { - SvelteComponent, - detach, - init, - insert, - noop, - safe_not_equal, - text -} from "svelte/internal"; - -function create_fragment(ctx) { - let t_value = foo.bar.baz + ""; - let t; - - return { - c() { - t = text(t_value); - }, - m(target, anchor) { - insert(target, t, anchor); - }, - p: noop, - i: noop, - o: noop, - d(detaching) { - if (detaching) detach(t); - } - }; -} - -let first; - -function assertThisLine() { - -} - -class Input extends SvelteComponent { - constructor(options) { - super(); - init(this, options, null, create_fragment, safe_not_equal, {}); - } -} - -export default Input; -export { first }; -//# sourceMappingURL=output.js.map \ No newline at end of file From 392bdc6b109727eb0f80d2eaf3770f947b27089c Mon Sep 17 00:00:00 2001 From: Alfred Ringstad Date: Wed, 31 Mar 2021 19:37:14 +0200 Subject: [PATCH 4/6] Add documentation --- site/content/docs/02-template-syntax.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/site/content/docs/02-template-syntax.md b/site/content/docs/02-template-syntax.md index 0e0e8e400822..6221dfe12482 100644 --- a/site/content/docs/02-template-syntax.md +++ b/site/content/docs/02-template-syntax.md @@ -1461,6 +1461,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 `tag` has a nullish value, a warning will be logged in development mode. + +```sv + + +Foo +``` ### `` From bd41b85d31e792dca8c26d69a019e784d10803c0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 26 Sep 2021 18:38:51 +0200 Subject: [PATCH 5/6] tag -> this --- site/content/docs/02-template-syntax.md | 6 +- src/compiler/parse/errors.ts | 8 +++ src/compiler/parse/state/tag.ts | 15 ++--- src/runtime/internal/dev.ts | 2 +- .../dynamic-element-string/input.svelte | 3 +- .../dynamic-element-string/output.json | 38 +++++++++++- .../dynamic-element-variable/input.svelte | 3 +- .../dynamic-element-variable/output.json | 59 +++++++++++++++++-- .../_config.js | 2 +- .../main.svelte | 2 +- .../main.svelte | 2 +- .../dynamic-element-binding-this/main.svelte | 2 +- .../dynamic-element-change-tag/main.svelte | 2 +- .../dynamic-element-event-handler/main.svelte | 2 +- .../dynamic-element-expression/main.svelte | 2 +- .../dynamic-element-pass-props/main.svelte | 2 +- .../main.svelte | 2 +- .../samples/dynamic-element-store/main.svelte | 2 +- .../dynamic-element-string/main.svelte | 2 +- .../dynamic-element-variable/main.svelte | 2 +- .../dynamic-element-string/main.svelte | 2 +- .../dynamic-element-variable/main.svelte | 4 +- 22 files changed, 124 insertions(+), 40 deletions(-) diff --git a/site/content/docs/02-template-syntax.md b/site/content/docs/02-template-syntax.md index e985234025bb..75ac845938d4 100644 --- a/site/content/docs/02-template-syntax.md +++ b/site/content/docs/02-template-syntax.md @@ -1551,7 +1551,7 @@ If `this` is falsy, no component is rendered. ### `` ```sv - + ``` --- @@ -1560,7 +1560,7 @@ The `` element lets you render an element of a dynamically speci 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 `tag` has a nullish value, a warning will be logged in development mode. +If `this` has a nullish value, a warning will be logged in development mode. ```sv -Foo +Foo ``` ### `` 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/main.svelte b/test/runtime/samples/dynamic-element-binding-invalid/main.svelte index 4639427fdf82..45f8f9606141 100644 --- a/test/runtime/samples/dynamic-element-binding-invalid/main.svelte +++ b/test/runtime/samples/dynamic-element-binding-invalid/main.svelte @@ -3,4 +3,4 @@ let value; - + diff --git a/test/runtime/samples/dynamic-element-binding-this/main.svelte b/test/runtime/samples/dynamic-element-binding-this/main.svelte index 0c1e3a3f15cb..75e8b02ce161 100644 --- a/test/runtime/samples/dynamic-element-binding-this/main.svelte +++ b/test/runtime/samples/dynamic-element-binding-this/main.svelte @@ -3,4 +3,4 @@ export let foo; - + diff --git a/test/runtime/samples/dynamic-element-change-tag/main.svelte b/test/runtime/samples/dynamic-element-change-tag/main.svelte index 8e30ac99c53b..a9c4d5c00c74 100644 --- a/test/runtime/samples/dynamic-element-change-tag/main.svelte +++ b/test/runtime/samples/dynamic-element-change-tag/main.svelte @@ -2,4 +2,4 @@ export let tag = 'div'; -Foo \ No newline at end of file +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-event-handler/main.svelte b/test/runtime/samples/dynamic-element-event-handler/main.svelte index 1fd8c41dcf0b..7a7fef9c22cf 100644 --- a/test/runtime/samples/dynamic-element-event-handler/main.svelte +++ b/test/runtime/samples/dynamic-element-event-handler/main.svelte @@ -3,4 +3,4 @@ export let handler; -Foo \ No newline at end of file +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-expression/main.svelte b/test/runtime/samples/dynamic-element-expression/main.svelte index f12ee38b9786..7ec11e4ef6d8 100644 --- a/test/runtime/samples/dynamic-element-expression/main.svelte +++ b/test/runtime/samples/dynamic-element-expression/main.svelte @@ -1 +1 @@ -Foo \ No newline at end of file +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-pass-props/main.svelte b/test/runtime/samples/dynamic-element-pass-props/main.svelte index dbd4e79abd74..6a54a93f2718 100644 --- a/test/runtime/samples/dynamic-element-pass-props/main.svelte +++ b/test/runtime/samples/dynamic-element-pass-props/main.svelte @@ -3,4 +3,4 @@ export let onClick; -Foo \ No newline at end of file +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-reuse-children/main.svelte b/test/runtime/samples/dynamic-element-reuse-children/main.svelte index a46765658f66..4be99aab09a6 100644 --- a/test/runtime/samples/dynamic-element-reuse-children/main.svelte +++ b/test/runtime/samples/dynamic-element-reuse-children/main.svelte @@ -2,7 +2,7 @@ export let tag = 'div', text = 'Foo'; - +
{text}
diff --git a/test/runtime/samples/dynamic-element-store/main.svelte b/test/runtime/samples/dynamic-element-store/main.svelte index 5b8098d24405..84a577ecee38 100644 --- a/test/runtime/samples/dynamic-element-store/main.svelte +++ b/test/runtime/samples/dynamic-element-store/main.svelte @@ -3,4 +3,4 @@ const foo = writable('div'); - \ No newline at end of file + \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-string/main.svelte b/test/runtime/samples/dynamic-element-string/main.svelte index 2cf3b2b1895b..62d65d5f203c 100644 --- a/test/runtime/samples/dynamic-element-string/main.svelte +++ b/test/runtime/samples/dynamic-element-string/main.svelte @@ -1 +1 @@ -Foo \ No newline at end of file +Foo \ No newline at end of file diff --git a/test/runtime/samples/dynamic-element-variable/main.svelte b/test/runtime/samples/dynamic-element-variable/main.svelte index 4cac42263573..d60953bba5de 100644 --- a/test/runtime/samples/dynamic-element-variable/main.svelte +++ b/test/runtime/samples/dynamic-element-variable/main.svelte @@ -3,4 +3,4 @@ export let text = "Foo"; -{text} \ No newline at end of file +{text} \ No newline at end of file diff --git a/test/server-side-rendering/samples/dynamic-element-string/main.svelte b/test/server-side-rendering/samples/dynamic-element-string/main.svelte index 2cf3b2b1895b..62d65d5f203c 100644 --- a/test/server-side-rendering/samples/dynamic-element-string/main.svelte +++ b/test/server-side-rendering/samples/dynamic-element-string/main.svelte @@ -1 +1 @@ -Foo \ No newline at end of file +Foo \ 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 index 45addc257614..9aaba18bf12f 100644 --- a/test/server-side-rendering/samples/dynamic-element-variable/main.svelte +++ b/test/server-side-rendering/samples/dynamic-element-variable/main.svelte @@ -3,5 +3,5 @@ let tag = 'div'; -Foo -Bar \ No newline at end of file +Foo +Bar \ No newline at end of file From 45d3ab099f36464f22e13fdffa0e63c617bbe401 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 21 Oct 2021 18:15:46 +0200 Subject: [PATCH 6/6] fix: css classes for dynamic elements --- src/compiler/compile/nodes/DynamicElement.ts | 4 ++++ test/css/samples/dynamic-element/_config.js | 20 +++++++++++++++++++ test/css/samples/dynamic-element/expected.css | 1 + .../css/samples/dynamic-element/expected.html | 1 + test/css/samples/dynamic-element/input.svelte | 10 ++++++++++ 5 files changed, 36 insertions(+) create mode 100644 test/css/samples/dynamic-element/_config.js create mode 100644 test/css/samples/dynamic-element/expected.css create mode 100644 test/css/samples/dynamic-element/expected.html create mode 100644 test/css/samples/dynamic-element/input.svelte diff --git a/src/compiler/compile/nodes/DynamicElement.ts b/src/compiler/compile/nodes/DynamicElement.ts index bb59ad44c9e4..eae94f47e4ef 100644 --- a/src/compiler/compile/nodes/DynamicElement.ts +++ b/src/compiler/compile/nodes/DynamicElement.ts @@ -99,6 +99,10 @@ export default class DynamicElement extends Node { 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() { diff --git a/test/css/samples/dynamic-element/_config.js b/test/css/samples/dynamic-element/_config.js new file mode 100644 index 000000000000..8d8811c57f1d --- /dev/null +++ b/test/css/samples/dynamic-element/_config.js @@ -0,0 +1,20 @@ +export default { + warnings: [ + { + code: "css-unused-selector", + end: { + character: 86, + column: 8, + line: 7, + }, + frame: " 5: color: red;\n 6: }\n 7: .unused {\n ^\n 8: font-style: italic;\n 9: }", + message: 'Unused CSS selector ".unused"', + pos: 79, + start: { + character: 79, + column: 1, + line: 7, + } + } + ] +}; diff --git a/test/css/samples/dynamic-element/expected.css b/test/css/samples/dynamic-element/expected.css new file mode 100644 index 000000000000..561f56a975c2 --- /dev/null +++ b/test/css/samples/dynamic-element/expected.css @@ -0,0 +1 @@ +.used.svelte-xyz{color:red} \ No newline at end of file diff --git a/test/css/samples/dynamic-element/expected.html b/test/css/samples/dynamic-element/expected.html new file mode 100644 index 000000000000..7b11bf31f41e --- /dev/null +++ b/test/css/samples/dynamic-element/expected.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/test/css/samples/dynamic-element/input.svelte b/test/css/samples/dynamic-element/input.svelte new file mode 100644 index 000000000000..c00410a96d8f --- /dev/null +++ b/test/css/samples/dynamic-element/input.svelte @@ -0,0 +1,10 @@ + + +