diff --git a/.eslintrc.js b/.eslintrc.js index a093de610b35..66c533eb5da0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,5 +10,8 @@ module.exports = { 'estree' ], 'svelte3/compiler': require('./compiler') + }, + rules: { + '@typescript-eslint/no-non-null-assertion': 'off' } }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 42457e0844bc..d1e76c170545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224)) * **breaking** Stricter types for `Action` and `ActionReturn` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224)) * **breaking** Stricter types for `onMount` - now throws a type error when returning a function asynchronously to catch potential mistakes around callback functions (see PR for migration instructions) ([#8136](https://github.com/sveltejs/svelte/pull/8136)) +* **breaking** Overhaul and drastically improve creating custom elements with Svelte (see PR for list of changes and migration instructions) ([#8457](https://github.com/sveltejs/svelte/pull/8457)) * **breaking** Deprecate `SvelteComponentTyped`, use `SvelteComponent` instead ([#8512](https://github.com/sveltejs/svelte/pull/8512)) * Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391)) * Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251)) diff --git a/elements/index.d.ts b/elements/index.d.ts index 4d6e9f1c7837..67f3b8c691ea 100644 --- a/elements/index.d.ts +++ b/elements/index.d.ts @@ -1597,7 +1597,17 @@ export interface SvelteHTMLElements { 'svelte:document': HTMLAttributes; 'svelte:body': HTMLAttributes; 'svelte:fragment': { slot?: string }; - 'svelte:options': { [name: string]: any }; + 'svelte:options': { + customElement?: string | undefined | { + tag: string; + shadow?: 'open' | 'none' | undefined; + props?: Record | undefined; + }; + immutable?: boolean | undefined; + accessors?: boolean | undefined; + namespace?: string | undefined; + [name: string]: any + }; 'svelte:head': { [name: string]: any }; [name: string]: { [name: string]: any }; diff --git a/rollup.config.mjs b/rollup.config.mjs index e745d3afaa67..54b988b51b38 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -15,9 +15,12 @@ const is_publish = !!process.env.PUBLISH; const ts_plugin = is_publish ? typescript({ typescript: require('typescript'), + paths: { + 'svelte/*': ['./src/runtime/*'] + } }) : sucrase({ - transforms: ['typescript'], + transforms: ['typescript'] }); fs.writeFileSync( diff --git a/site/content/docs/03-template-syntax.md b/site/content/docs/03-template-syntax.md index 170c303b8524..a59b8726910d 100644 --- a/site/content/docs/03-template-syntax.md +++ b/site/content/docs/03-template-syntax.md @@ -1825,10 +1825,10 @@ The `` element provides a place to specify per-component compile * `accessors={true}` — adds getters and setters for the component's props * `accessors={false}` — the default * `namespace="..."` — the namespace where this component will be used, most commonly "svg"; use the "foreign" namespace to opt out of case-insensitive attribute names and HTML-specific warnings -* `tag="..."` — the name to use when compiling this component as a custom element +* `customElement="..."` — the name to use when compiling this component as a custom element ```sv - + ``` ### `` diff --git a/site/content/docs/04-run-time.md b/site/content/docs/04-run-time.md index 440d8aeccfaa..5021bc14f93e 100644 --- a/site/content/docs/04-run-time.md +++ b/site/content/docs/04-run-time.md @@ -1118,7 +1118,7 @@ app.count += 1; Svelte components can also be compiled to custom elements (aka web components) using the `customElement: true` compiler option. You should specify a tag name for the component using the `` [element](/docs#template-syntax-svelte-options). ```sv - + + +... +``` + Custom elements can be a useful way to package components for consumption in a non-Svelte app, as they will work with vanilla HTML and JavaScript as well as [most frameworks](https://custom-elements-everywhere.com/). There are, however, some important differences to be aware of: -* Styles are *encapsulated*, rather than merely *scoped*. This means that any non-component styles (such as you might have in a `global.css` file) will not apply to the custom element, including styles with the `:global(...)` modifier +* Styles are *encapsulated*, rather than merely *scoped* (unless you set `shadow: "none"`). This means that any non-component styles (such as you might have in a `global.css` file) will not apply to the custom element, including styles with the `:global(...)` modifier * Instead of being extracted out as a separate .css file, styles are inlined into the component as a JavaScript string * Custom elements are not generally suitable for server-side rendering, as the shadow DOM is invisible until JavaScript loads * In Svelte, slotted content renders *lazily*. In the DOM, it renders *eagerly*. In other words, it will always be created even if the component's `` element is inside an `{#if ...}` block. Similarly, including a `` in an `{#each ...}` block will not cause the slotted content to be rendered multiple times -* The `let:` directive has no effect +* The `let:` directive has no effect, because custom elements do not have a way to pass data to the parent component that fills the slot * Polyfills are required to support older browsers +When a custom element written with Svelte is created or updated, the shadow dom will reflect the value in the next tick, not immediately. This way updates can be batched, and DOM moves which temporarily (but synchronously) detach the element from the DOM don't lead to unmounting the inner component. ### Server-side component API diff --git a/site/content/tutorial/16-special-elements/09-svelte-options/text.md b/site/content/tutorial/16-special-elements/09-svelte-options/text.md index 1a0105a09af8..2783945b7681 100644 --- a/site/content/tutorial/16-special-elements/09-svelte-options/text.md +++ b/site/content/tutorial/16-special-elements/09-svelte-options/text.md @@ -25,6 +25,6 @@ The options that can be set here are: * `accessors={true}` — adds getters and setters for the component's props * `accessors={false}` — the default * `namespace="..."` — the namespace where this component will be used, most commonly `"svg"` -* `tag="..."` — the name to use when compiling this component as a custom element +* `customElement="..."` — the name to use when compiling this component as a custom element Consult the [API reference](/docs) for more information on these options. diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index b9d78ae7bd30..2ca04f3adf62 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -16,7 +16,7 @@ import Stylesheet from './css/Stylesheet'; import { test } from '../config'; import Fragment from './nodes/Fragment'; import internal_exports from './internal_exports'; -import { Ast, CompileOptions, Var, Warning, CssResult } from '../interfaces'; +import { Ast, CompileOptions, Var, Warning, CssResult, Attribute } from '../interfaces'; import error from '../utils/error'; import get_code_frame from '../utils/get_code_frame'; import flatten_reference from './utils/flatten_reference'; @@ -26,7 +26,7 @@ import TemplateScope from './nodes/shared/TemplateScope'; import fuzzymatch from '../utils/fuzzymatch'; import get_object from './utils/get_object'; import Slot from './nodes/Slot'; -import { Node, ImportDeclaration, ExportNamedDeclaration, Identifier, ExpressionStatement, AssignmentExpression, Literal, Property, RestElement, ExportDefaultDeclaration, ExportAllDeclaration, FunctionDeclaration, FunctionExpression } from 'estree'; +import { Node, ImportDeclaration, ExportNamedDeclaration, Identifier, ExpressionStatement, AssignmentExpression, Literal, Property, RestElement, ExportDefaultDeclaration, ExportAllDeclaration, FunctionDeclaration, FunctionExpression, ObjectExpression } from 'estree'; import add_to_set from './utils/add_to_set'; import check_graph_for_cycles from './utils/check_graph_for_cycles'; import { print, b } from 'code-red'; @@ -42,10 +42,14 @@ import Tag from './nodes/shared/Tag'; interface ComponentOptions { namespace?: string; - tag?: string; immutable?: boolean; accessors?: boolean; preserveWhitespace?: boolean; + customElement?: { + tag: string | null; + shadow?: 'open' | 'none'; + props?: Record; + }; } const regex_leading_directory_separator = /^[/\\]/; @@ -167,16 +171,7 @@ export default class Component { this.component_options.namespace; if (compile_options.customElement) { - if ( - this.component_options.tag === undefined && - compile_options.tag === undefined - ) { - const svelteOptions = ast.html.children.find( - child => child.name === 'svelte:options' - ) || { start: 0, end: 0 }; - this.warn(svelteOptions, compiler_warnings.custom_element_no_tag); - } - this.tag = this.component_options.tag || compile_options.tag; + this.tag = this.component_options.customElement?.tag || compile_options.tag || this.name.name; } else { this.tag = this.name.name; } @@ -195,7 +190,7 @@ export default class Component { this.pop_ignores(); this.elements.forEach(element => this.stylesheet.apply(element)); - if (!compile_options.customElement) this.stylesheet.reify(); + this.stylesheet.reify(); this.stylesheet.warn_on_unused_selectors(this); } @@ -547,6 +542,9 @@ export default class Component { extract_names(declarator.id).forEach(name => { const variable = this.var_lookup.get(name); variable.export_name = name; + if (declarator.init?.type === 'Literal' && typeof declarator.init.value === 'boolean') { + variable.is_boolean = true; + } if (!module_script && variable.writable && !(variable.referenced || variable.referenced_from_script || variable.subscribable)) { this.warn(declarator as any, compiler_warnings.unused_export_let(this.name.name, name)); } @@ -1560,23 +1558,99 @@ function process_component_options(component: Component, nodes) { if (attribute.type === 'Attribute') { const { name } = attribute; + function parse_tag(attribute: Attribute, tag: string) { + if (typeof tag !== 'string' && tag !== null) { + return component.error(attribute, compiler_errors.invalid_tag_attribute); + } + + if (tag && !regex_valid_tag_name.test(tag)) { + return component.error(attribute, compiler_errors.invalid_tag_property); + } + + if (tag && !component.compile_options.customElement) { + component.warn(attribute, compiler_warnings.missing_custom_element_compile_options); + } + + component_options.customElement = component_options.customElement || {} as any; + component_options.customElement.tag = tag; + } + switch (name) { case 'tag': { - const tag = get_value(attribute, compiler_errors.invalid_tag_attribute); + component.warn(attribute, compiler_warnings.tag_option_deprecated); + parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute)); + break; + } - if (typeof tag !== 'string' && tag !== null) { - return component.error(attribute, compiler_errors.invalid_tag_attribute); + case 'customElement': { + component_options.customElement = component_options.customElement || {} as any; + + const { value } = attribute; + + if (value[0].type === 'MustacheTag' && value[0].expression?.value === null) { + component_options.customElement.tag = null; + break; + } else if (value[0].type === 'Text') { + parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute)); + break; + } else if (value[0].expression.type !== 'ObjectExpression') { + return component.error(attribute, compiler_errors.invalid_customElement_attribute); } - if (tag && !regex_valid_tag_name.test(tag)) { - return component.error(attribute, compiler_errors.invalid_tag_property); + const tag = value[0].expression.properties.find( + (prop: any) => prop.key.name === 'tag' + ); + if (tag) { + parse_tag(tag, tag.value?.value); + } else { + return component.error(attribute, compiler_errors.invalid_customElement_attribute); } - if (tag && !component.compile_options.customElement) { - component.warn(attribute, compiler_warnings.missing_custom_element_compile_options); + const props = value[0].expression.properties.find( + (prop: any) => prop.key.name === 'props' + ); + if (props) { + const error = () => component.error(attribute, compiler_errors.invalid_props_attribute); + if (props.value?.type !== 'ObjectExpression') { + return error(); + } + + component_options.customElement.props = {}; + + for (const property of (props.value as ObjectExpression).properties) { + if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression') { + return error(); + } + component_options.customElement.props[property.key.name] = {}; + for (const prop of property.value.properties) { + if (prop.type !== 'Property' || prop.computed || prop.key.type !== 'Identifier' || prop.value.type !== 'Literal') { + return error(); + } + if (['reflect', 'attribute', 'type'].indexOf(prop.key.name) === -1 || + prop.key.name === 'type' && ['String', 'Number', 'Boolean', 'Array', 'Object'].indexOf(prop.value.value as string) === -1 || + prop.key.name === 'reflect' && typeof prop.value.value !== 'boolean' || + prop.key.name === 'attribute' && typeof prop.value.value !== 'string' + ) { + return error(); + } + component_options.customElement.props[property.key.name][prop.key.name] = prop.value.value; + } + } + } + + const shadow = value[0].expression.properties.find( + (prop: any) => prop.key.name === 'shadow' + ); + if (shadow) { + const shadowdom = shadow.value?.value; + + if (shadowdom !== 'open' && shadowdom !== 'none') { + return component.error(shadow, compiler_errors.invalid_shadow_attribute); + } + + component_options.customElement.shadow = shadowdom; } - component_options.tag = tag; break; } @@ -1610,7 +1684,7 @@ function process_component_options(component: Component, nodes) { } default: - return component.error(attribute, compiler_errors.invalid_options_attribute_unknown); + return component.error(attribute, compiler_errors.invalid_options_attribute_unknown(name)); } } else { return component.error(attribute, compiler_errors.invalid_options_attribute); diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index bad3911673fc..860fa20d9f65 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -202,10 +202,24 @@ export default { code: 'invalid-tag-property', message: "tag name must be two or more words joined by the '-' character" }, + invalid_customElement_attribute: { + code: 'invalid-customElement-attribute', + message: "'customElement' must be a string literal defining a valid custom element name or an object of the form " + + "{ tag: string; shadow?: 'open' | 'none'; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } }" + }, invalid_tag_attribute: { code: 'invalid-tag-attribute', message: "'tag' must be a string literal" }, + invalid_shadow_attribute: { + code: 'invalid-shadow-attribute', + message: "'shadow' must be either 'open' or 'none'" + }, + invalid_props_attribute: { + code: 'invalid-props-attribute', + message: "'props' must be a statically analyzable object literal of the form " + + "'{ [key: string]: { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }'" + }, invalid_namespace_property: (namespace: string, suggestion?: string) => ({ code: 'invalid-namespace-property', message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '') @@ -218,10 +232,10 @@ export default { code: `invalid-${name}-value`, message: `${name} attribute must be true or false` }), - invalid_options_attribute_unknown: { + invalid_options_attribute_unknown: (name: string) => ({ code: 'invalid-options-attribute', - message: ' unknown attribute' - }, + message: ` unknown attribute '${name}'` + }), invalid_options_attribute: { code: 'invalid-options-attribute', message: " can only have static 'tag', 'namespace', 'accessors', 'immutable' and 'preserveWhitespace' attributes" diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index 2138e81213ae..daa02d5121c5 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -6,9 +6,9 @@ import { ARIAPropertyDefinition } from 'aria-query'; * @internal */ export default { - custom_element_no_tag: { - code: 'custom-element-no-tag', - message: 'No custom element \'tag\' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. . To hide this warning, use ' + tag_option_deprecated: { + code: 'tag-option-deprecated', + message: "'tag' option is deprecated — use 'customElement' instead" }, unused_export_let: (component: string, property: string) => ({ code: 'unused-export-let', @@ -32,7 +32,7 @@ export default { }), missing_custom_element_compile_options: { code: 'missing-custom-element-compile-options', - message: "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?" + message: "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?" }, css_unused_selector: (selector: string) => ({ code: 'css-unused-selector', diff --git a/src/compiler/compile/css/Stylesheet.ts b/src/compiler/compile/css/Stylesheet.ts index 7cb1af36358f..9355fb816cd8 100644 --- a/src/compiler/compile/css/Stylesheet.ts +++ b/src/compiler/compile/css/Stylesheet.ts @@ -407,7 +407,7 @@ export default class Stylesheet { }); } - render(file: string, should_transform_selectors: boolean) { + render(file: string) { if (!this.has_styles) { return { code: null, map: null }; } @@ -421,12 +421,10 @@ export default class Stylesheet { } }); - if (should_transform_selectors) { - const max = Math.max(...this.children.map(rule => rule.get_max_amount_class_specificity_increased())); - this.children.forEach((child: (Atrule | Rule)) => { - child.transform(code, this.id, this.keyframes, max); - }); - } + const max = Math.max(...this.children.map(rule => rule.get_max_amount_class_specificity_increased())); + this.children.forEach((child: (Atrule | Rule)) => { + child.transform(code, this.id, this.keyframes, max); + }); let c = 0; this.children.forEach(child => { diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index 879bc93a9552..d2188c96a114 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -7,12 +7,11 @@ import { walk } from 'estree-walker'; import { extract_names, Scope } from 'periscopic'; import { invalidate } from './invalidate'; import Block from './Block'; -import { ImportDeclaration, ClassDeclaration, FunctionExpression, Node, Statement, ObjectExpression, Expression } from 'estree'; +import { ImportDeclaration, ClassDeclaration, Node, Statement, ObjectExpression, Expression } from 'estree'; import { apply_preprocessor_sourcemap } from '../../utils/mapped_code'; import { flatten } from '../../utils/flatten'; import check_enable_sourcemap from '../utils/check_enable_sourcemap'; import { push_array } from '../../utils/push_array'; -import { regex_backslashes } from '../../utils/patterns'; export default function dom( component: Component, @@ -25,9 +24,6 @@ export default function dom( block.has_outro_method = true; - // prevent fragment being created twice (#1063) - if (options.customElement) block.chunks.create.push(b`this.c = @noop;`); - const body = []; if (renderer.file_var) { @@ -35,7 +31,7 @@ export default function dom( body.push(b`const ${renderer.file_var} = ${file};`); } - const css = component.stylesheet.render(options.filename, !options.customElement); + const css = component.stylesheet.render(options.filename); const css_sourcemap_enabled = check_enable_sourcemap(options.enableSourcemap, 'css'); @@ -52,9 +48,8 @@ export default function dom( const add_css = component.get_unique_name('add_css'); const should_add_css = ( - !options.customElement && !!styles && - options.css === 'injected' + (options.customElement || options.css === 'injected') ); if (should_add_css) { @@ -519,91 +514,56 @@ export default function dom( } } - if (options.customElement) { - - let init_props = x`@attribute_to_object(this.attributes)`; - if (uses_slots) { - init_props = x`{ ...${init_props}, $$slots: @get_custom_elements_slots(this) }`; - } - - const declaration = b` - class ${name} extends @SvelteElement { - constructor(options) { - super(); - - ${css.code && b` - const style = document.createElement('style'); - style.textContent = \`${css.code.replace(regex_backslashes, '\\\\')}${css_sourcemap_enabled && options.dev ? `\n/*# sourceMappingURL=${css.map.toUrl()} */` : ''}\` - this.shadowRoot.appendChild(style)`} + const superclass = { + type: 'Identifier', + name: options.dev ? '@SvelteComponentDev' : '@SvelteComponent' + }; - @init(this, { target: this.shadowRoot, props: ${init_props}, customElement: true }, ${definition}, ${has_create_fragment ? 'create_fragment' : 'null'}, ${not_equal}, ${prop_indexes}, null, ${dirty}); - - if (options) { - if (options.target) { - @insert(options.target, this, options.anchor); - } + const optional_parameters = []; + if (should_add_css) { + optional_parameters.push(add_css); + } else if (dirty) { + optional_parameters.push(x`null`); + } + if (dirty) { + optional_parameters.push(dirty); + } - ${(props.length > 0 || uses_props || uses_rest) && b` - if (options.props) { - this.$set(options.props); - @flush(); - }`} - } - } + const declaration = b` + class ${name} extends ${superclass} { + constructor(options) { + super(${options.dev && 'options'}); + @init(this, options, ${definition}, ${has_create_fragment ? 'create_fragment' : 'null'}, ${not_equal}, ${prop_indexes}, ${optional_parameters}); + ${options.dev && b`@dispatch_dev("SvelteRegisterComponent", { component: this, tagName: "${name.name}", options, id: create_fragment.name });`} } - `[0] as ClassDeclaration; - - if (props.length > 0) { - declaration.body.body.push({ - type: 'MethodDefinition', - kind: 'get', - static: true, - computed: false, - key: { type: 'Identifier', name: 'observedAttributes' }, - value: x`function() { - return [${props.map(prop => x`"${prop.export_name}"`)}]; - }` as FunctionExpression - }); } + `[0] as ClassDeclaration; - push_array(declaration.body.body, accessors); - - body.push(declaration); - - if (component.tag != null) { - body.push(b` - @_customElements.define("${component.tag}", ${name}); - `); - } - } else { - const superclass = { - type: 'Identifier', - name: options.dev ? '@SvelteComponentDev' : '@SvelteComponent' - }; + push_array(declaration.body.body, accessors); + body.push(declaration); - const optional_parameters = []; - if (should_add_css) { - optional_parameters.push(add_css); - } else if (dirty) { - optional_parameters.push(x`null`); - } - if (dirty) { - optional_parameters.push(dirty); - } - - const declaration = b` - class ${name} extends ${superclass} { - constructor(options) { - super(${options.dev && 'options'}); - @init(this, options, ${definition}, ${has_create_fragment ? 'create_fragment' : 'null'}, ${not_equal}, ${prop_indexes}, ${optional_parameters}); - ${options.dev && b`@dispatch_dev("SvelteRegisterComponent", { component: this, tagName: "${name.name}", options, id: create_fragment.name });`} - } + if (options.customElement) { + const props_str = writable_props.reduce((def, prop) => { + def[prop.export_name] = component.component_options.customElement?.props?.[prop.export_name] || {}; + if (prop.is_boolean && !def[prop.export_name].type) { + def[prop.export_name].type = 'Boolean'; } - `[0] as ClassDeclaration; - - push_array(declaration.body.body, accessors); - - body.push(declaration); + return def; + }, {}); + const slots_str = [...component.slots.keys()].map(key => `"${key}"`).join(','); + const accessors_str = accessors + .filter(accessor => !writable_props.some(prop => prop.export_name === accessor.key.name)) + .map(accessor => `"${accessor.key.name}"`) + .join(','); + const use_shadow_dom = component.component_options.customElement?.shadow !== 'none' ? 'true' : 'false'; + + if (component.component_options.customElement?.tag) { + body.push( + b`@_customElements.define("${component.component_options.customElement.tag}", @create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom}));` + ); + } else { + body.push(b`@create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom});`); + } } return { js: flatten(body), css }; diff --git a/src/compiler/compile/render_ssr/index.ts b/src/compiler/compile/render_ssr/index.ts index e256ba78fbf0..d1fc816cde37 100644 --- a/src/compiler/compile/render_ssr/index.ts +++ b/src/compiler/compile/render_ssr/index.ts @@ -33,7 +33,7 @@ export default function ssr( // TODO concatenate CSS maps const css = options.customElement ? { code: null, map: null } : - component.stylesheet.render(options.filename, true); + component.stylesheet.render(options.filename); const uses_rest = component.var_lookup.has('$$restProps'); const props = component.vars.filter(variable => !variable.module && variable.export_name); diff --git a/src/compiler/interfaces.ts b/src/compiler/interfaces.ts index 4ae53594bb91..5767f1523d20 100644 --- a/src/compiler/interfaces.ts +++ b/src/compiler/interfaces.ts @@ -206,7 +206,10 @@ export interface AppendTarget { export interface Var { name: string; - export_name?: string; // the `bar` in `export { foo as bar }` + /** the `bar` in `export { foo as bar }` or `export let bar` */ + export_name?: string; + /** true if assigned a boolean default value (`export let foo = true`) */ + is_boolean?: boolean; injected?: boolean; module?: boolean; mutated?: boolean; diff --git a/src/compiler/parse/state/tag.ts b/src/compiler/parse/state/tag.ts index 90adfb8e79dc..f7d02a09507c 100644 --- a/src/compiler/parse/state/tag.ts +++ b/src/compiler/parse/state/tag.ts @@ -115,7 +115,7 @@ export default function tag(parser: Parser) { : (regex_capital_letter.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 === 'slot' ? 'Slot' : 'Element'; const element: TemplateNode = { start, diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index a8a500b25b02..2a7be0f4f1b1 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -1,9 +1,10 @@ import { add_render_callback, flush, flush_render_callbacks, schedule_update, dirty_components } from './scheduler'; import { current_component, set_current_component } from './lifecycle'; import { blank_object, is_empty, is_function, run, run_all, noop } from './utils'; -import { children, detach, start_hydrating, end_hydrating } from './dom'; +import { children, detach, start_hydrating, end_hydrating, get_custom_elements_slots, insert } from './dom'; import { transition_in } from './transitions'; import { T$$ } from './types'; +import { ComponentType } from './dev'; export function bind(component, name, callback) { const index = component.$$.props[name]; @@ -21,29 +22,27 @@ export function claim_component(block, parent_nodes) { block && block.l(parent_nodes); } -export function mount_component(component, target, anchor, customElement) { +export function mount_component(component, target, anchor) { const { fragment, after_update } = component.$$; fragment && fragment.m(target, anchor); - if (!customElement) { - // onMount happens before the initial afterUpdate - add_render_callback(() => { - - const new_on_destroy = component.$$.on_mount.map(run).filter(is_function); - // if the component was destroyed immediately - // it will update the `$$.on_destroy` reference to `null`. - // the destructured on_destroy may still reference to the old array - if (component.$$.on_destroy) { - component.$$.on_destroy.push(...new_on_destroy); - } else { - // Edge case - component was destroyed immediately, - // most likely as a result of a binding initialising - run_all(new_on_destroy); - } - component.$$.on_mount = []; - }); - } + // onMount happens before the initial afterUpdate + add_render_callback(() => { + + const new_on_destroy = component.$$.on_mount.map(run).filter(is_function); + // if the component was destroyed immediately + // it will update the `$$.on_destroy` reference to `null`. + // the destructured on_destroy may still reference to the old array + if (component.$$.on_destroy) { + component.$$.on_destroy.push(...new_on_destroy); + } else { + // Edge case - component was destroyed immediately, + // most likely as a result of a binding initialising + run_all(new_on_destroy); + } + component.$$.on_mount = []; + }); after_update.forEach(add_render_callback); } @@ -137,7 +136,7 @@ export function init(component, options, instance, create_fragment, not_equal, p } if (options.intro) transition_in(component.$$.fragment); - mount_component(component, options.target, options.anchor, options.customElement); + mount_component(component, options.target, options.anchor); end_hydrating(); flush(); } @@ -148,59 +147,257 @@ export function init(component, options, instance, create_fragment, not_equal, p export let SvelteElement; if (typeof HTMLElement === 'function') { SvelteElement = class extends HTMLElement { - $$: T$$; - $$set?: ($$props: any) => void; - constructor() { + private $$component?: SvelteComponent; + private $$connected = false; + private $$data = {}; + private $$reflecting = false; + private $$props_definition: Record = {}; + private $$listeners: Record = {}; + private $$listener_unsubscribe_fns = new Map(); + + constructor( + private $$componentCtor: ComponentType, + private $$slots: string[], + use_shadow_dom: boolean + ) { super(); - this.attachShadow({ mode: 'open' }); + if (use_shadow_dom) { + this.attachShadow({ mode: 'open' }); + } + } + + addEventListener(type: string, listener: any, options?: any): void { + // We can't determine upfront if the event is a custom event or not, so we have to + // listen to both. If someone uses a custom event with the same name as a regular + // browser event, this fires twice - we can't avoid that. + this.$$listeners[type] = this.$$listeners[type] || []; + this.$$listeners[type].push(listener); + if (this.$$component) { + const unsub = this.$$component!.$on(type, listener); + this.$$listener_unsubscribe_fns.set(listener, unsub); + } + super.addEventListener(type, listener, options); } - connectedCallback() { - const { on_mount } = this.$$; - this.$$.on_disconnect = on_mount.map(run).filter(is_function); + removeEventListener(type: string, listener: any, options?: any): void { + super.removeEventListener(type, listener, options); + if (this.$$component) { + const unsub = this.$$listener_unsubscribe_fns.get(listener); + if (unsub) { + unsub(); + this.$$listener_unsubscribe_fns.delete(listener); + } + } + } - // @ts-ignore todo: improve typings - for (const key in this.$$.slotted) { - // @ts-ignore todo: improve typings - this.appendChild(this.$$.slotted[key]); + async connectedCallback() { + this.$$connected = true; + if (!this.$$component) { + // We wait one tick to let possible child slot elements be created/mounted + await Promise.resolve(); + + if (!this.$$connected) { + return; + } + + function create_slot(name: string) { + return () => { + let node: HTMLSlotElement; + const obj = { + c: function create() { + node = document.createElement('slot'); + if (name !== 'default') { + node.setAttribute('name', name); + } + }, + m: function mount(target: HTMLElement, anchor?: HTMLElement) { + insert(target, node, anchor); + }, + d: function destroy(detaching: boolean) { + if (detaching) { + detach(node); + } + } + }; + return obj; + }; + } + + const $$slots: Record = {}; + const existing_slots = get_custom_elements_slots(this); + for (const name of this.$$slots) { + if (name in existing_slots) { + $$slots[name] = [create_slot(name)]; + } + } + + for (const attribute of this.attributes) { + // this.$$data takes precedence over this.attributes + const name = this.$$get_prop_name(attribute.name); + if (!(name in this.$$data)) { + this.$$data[name] = get_custom_element_value(name, attribute.value, this.$$props_definition, 'toProp'); + } + } + + this.$$component = new this.$$componentCtor({ + target: this.shadowRoot || this, + props: { + ...this.$$data, + $$slots, + $$scope: { + ctx: [] + } + } + }); + + for (const type in this.$$listeners) { + for (const listener of this.$$listeners[type]) { + const unsub = this.$$component!.$on(type, listener); + this.$$listener_unsubscribe_fns.set(listener, unsub); + } + } + this.$$listeners = {}; } } - attributeChangedCallback(attr, _oldValue, newValue) { - this[attr] = newValue; + // We don't need this when working within Svelte code, but for compatibility of people using this outside of Svelte + // and setting attributes through setAttribute etc, this is helpful + attributeChangedCallback(attr: string, _oldValue: any, newValue: any) { + if (this.$$reflecting) return; + + attr = this.$$get_prop_name(attr); + this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$props_definition, 'toProp'); + this.$$component!.$set({ [attr]: this.$$data[attr] }); } disconnectedCallback() { - run_all(this.$$.on_disconnect); + this.$$connected = false; + // In a microtask, because this could be a move within the DOM + Promise.resolve().then(() => { + if (!this.$$connected) { + this.$$component!.$destroy(); + this.$$component = undefined; + } + }); } - $destroy() { - destroy_component(this, 1); - this.$destroy = noop; + private $$get_prop_name(attribute_name: string): string { + return Object.keys(this.$$props_definition).find( + key => this.$$props_definition[key].attribute === attribute_name || + (!this.$$props_definition[key].attribute && key.toLowerCase() === attribute_name) + ) || attribute_name; } + }; +} - $on(type, callback) { - // TODO should this delegate to addEventListener? - if (!is_function(callback)) { - return noop; - } - const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = [])); - callbacks.push(callback); +function get_custom_element_value(prop: string, value: any, props_definition: Record, transform?: 'toAttribute' | 'toProp') { + const type = props_definition[prop]?.type; + value = type === 'Boolean' && typeof value !== 'boolean' ? value != null : value; + if (!transform || !props_definition[prop]) { + return value; + } else if (transform === 'toAttribute') { + switch (type) { + case 'Object': + case 'Array': + return value == null ? null : JSON.stringify(value); + case 'Boolean': + return value ? '' : null; + case 'Number': + return value == null ? null : value; + default: + return value; + } + } else { + switch (type) { + case 'Object': + case 'Array': + return value && JSON.parse(value); + case 'Boolean': + return value; // conversion already handled above + case 'Number': + return value != null ? +value : value; + default: + return value; + } + } +} + +interface CustomElementPropDefinition { + attribute?: string; + reflect?: boolean; + type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object'; +} - return () => { - const index = callbacks.indexOf(callback); - if (index !== -1) callbacks.splice(index, 1); - }; +/** + * @internal + * + * Turn a Svelte component into a custom element. + * @param Component A Svelte component constructor + * @param props_definition The props to observe + * @param slots The slots to create + * @param accessors Other accessors besides the ones for props the component has + * @param use_shadow_dom Whether to use shadow DOM + * @returns A custom element class + */ +export function create_custom_element( + Component: ComponentType, + props_definition: Record, + slots: string[], + accessors: string[], + use_shadow_dom: boolean +) { + const Class = class extends SvelteElement { + constructor() { + super(Component, slots, use_shadow_dom); + this.$$props_definition = props_definition; } - $set($$props) { - if (this.$$set && !is_empty($$props)) { - this.$$.skip_bound = true; - this.$$set($$props); - this.$$.skip_bound = false; - } + static get observedAttributes() { + return Object.keys(props_definition).map(key => (props_definition[key].attribute || key).toLowerCase()); } }; + + Object.keys(props_definition).forEach((prop) => { + Object.defineProperty(Class.prototype, prop, { + get() { + return this.$$component && prop in this.$$component + ? this.$$component[prop] + : this.$$data[prop]; + }, + + set(value) { + value = get_custom_element_value(prop, value, props_definition); + this.$$data[prop] = value; + this.$$component?.$set({ [prop]: value }); + + if (props_definition[prop].reflect) { + this.$$reflecting = true; + const attribute_value = get_custom_element_value(prop, value, props_definition, 'toAttribute'); + if (attribute_value == null) { + this.removeAttribute(prop); + } else { + this.setAttribute( + props_definition[prop].attribute || prop, + attribute_value as string + ); + } + this.$$reflecting = false; + } + } + }); + }); + + accessors.forEach(accessor => { + Object.defineProperty(Class.prototype, accessor, { + get() { + return this.$$component?.[accessor]; + } + }); + }); + + Component.element = Class as any; + + return Class; } /** diff --git a/src/runtime/internal/dev.ts b/src/runtime/internal/dev.ts index efdd3a3469c6..132b1e4bbd03 100644 --- a/src/runtime/internal/dev.ts +++ b/src/runtime/internal/dev.ts @@ -284,11 +284,14 @@ export class SvelteComponentTyped< * * ``` */ -export type ComponentType = new ( +export type ComponentType = (new ( options: ComponentConstructorOptions< Component extends SvelteComponentDev ? Props : Record > -) => Component; +) => Component) & { + /** The custom element version of the component. Only present if compiled with the `customElement` compiler option */ + element?: typeof HTMLElement +}; /** * Convenience type to get the props the given component expects. Example: diff --git a/test/custom-elements/samples/$$props/main.svelte b/test/custom-elements/samples/$$props/main.svelte index 68931e22db79..22d4db74b1e1 100644 --- a/test/custom-elements/samples/$$props/main.svelte +++ b/test/custom-elements/samples/$$props/main.svelte @@ -1,4 +1,4 @@ - + + + +

default {name}

+
diff --git a/test/custom-elements/samples/$$slot-dynamic-content/my-widget.svelte b/test/custom-elements/samples/$$slot-dynamic-content/my-widget.svelte new file mode 100644 index 000000000000..d4e073e088b9 --- /dev/null +++ b/test/custom-elements/samples/$$slot-dynamic-content/my-widget.svelte @@ -0,0 +1,4 @@ + + +fallback +

named fallback

diff --git a/test/custom-elements/samples/$$slot-dynamic-content/test.js b/test/custom-elements/samples/$$slot-dynamic-content/test.js new file mode 100644 index 000000000000..629e6f5eb7f2 --- /dev/null +++ b/test/custom-elements/samples/$$slot-dynamic-content/test.js @@ -0,0 +1,22 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import Component from './main.svelte'; + +export default async function (target) { + const component = new Component({ target, props: { name: 'slot' } }); + await tick(); + await tick(); + + const ce = target.querySelector('my-widget'); + + assert.htmlEqual(ce.shadowRoot.innerHTML, ` + +

named fallback

+ `); + + component.name = 'slot2'; + assert.htmlEqual(ce.shadowRoot.innerHTML, ` + +

named fallback

+ `); +} diff --git a/test/custom-elements/samples/$$slot/main.svelte b/test/custom-elements/samples/$$slot/main.svelte index 05e1ac328443..c107a0ecbd6b 100644 --- a/test/custom-elements/samples/$$slot/main.svelte +++ b/test/custom-elements/samples/$$slot/main.svelte @@ -1,8 +1,10 @@ + + - - - - + +

$$slots: {toString($$slots)}

{#if $$slots.b}
- +
{:else}

Slot b is not available

-{/if} \ No newline at end of file +{/if} diff --git a/test/custom-elements/samples/$$slot/test.js b/test/custom-elements/samples/$$slot/test.js index 567e93f509e9..59c6ba8b224c 100644 --- a/test/custom-elements/samples/$$slot/test.js +++ b/test/custom-elements/samples/$$slot/test.js @@ -1,11 +1,13 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = ` hello worldbyeworld hello worldhello worldbye world `; + await tick(); const [a, b] = target.querySelectorAll('custom-element'); diff --git a/test/custom-elements/samples/action/main.svelte b/test/custom-elements/samples/action/main.svelte new file mode 100644 index 000000000000..0d88504b8751 --- /dev/null +++ b/test/custom-elements/samples/action/main.svelte @@ -0,0 +1,20 @@ + + + + +
action
diff --git a/test/custom-elements/samples/action/test.js b/test/custom-elements/samples/action/test.js new file mode 100644 index 000000000000..4619ae85687b --- /dev/null +++ b/test/custom-elements/samples/action/test.js @@ -0,0 +1,19 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + await tick(); + const el = target.querySelector('custom-element'); + const events = el.events; // need to get the array reference, else it's gone when destroyed + assert.deepEqual(events, ['foo']); + + el.name = 'bar'; + await tick(); + assert.deepEqual(events, ['foo', 'bar']); + + target.innerHTML = ''; + await tick(); + assert.deepEqual(events, ['foo', 'bar', 'destroy']); +} diff --git a/test/custom-elements/samples/camel-case-attribute/main.svelte b/test/custom-elements/samples/camel-case-attribute/main.svelte new file mode 100644 index 000000000000..b84870c48aff --- /dev/null +++ b/test/custom-elements/samples/camel-case-attribute/main.svelte @@ -0,0 +1,21 @@ + + + + +

{camelCase2} {camelCase}!

+{#each anArray as item} +

{item}

+{/each} diff --git a/test/custom-elements/samples/camel-case-attribute/test.js b/test/custom-elements/samples/camel-case-attribute/test.js new file mode 100644 index 000000000000..6a8d044cf711 --- /dev/null +++ b/test/custom-elements/samples/camel-case-attribute/test.js @@ -0,0 +1,25 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + await tick(); + const el = target.querySelector('custom-element'); + + assert.equal(el.shadowRoot.innerHTML, '

Hello world!

1

2

'); + + el.setAttribute('camel-case', 'universe'); + el.setAttribute('an-array', '[3,4]'); + el.setAttribute('camelcase2', 'Hi'); + await tick(); + assert.equal(el.shadowRoot.innerHTML, '

Hi universe!

3

4

'); + assert.equal(target.innerHTML, ''); + + el.camelCase = 'galaxy'; + el.camelCase2 = 'Hey'; + el.anArray = [5, 6]; + await tick(); + assert.equal(el.shadowRoot.innerHTML, '

Hey galaxy!

5

6

'); + assert.equal(target.innerHTML, ''); +} diff --git a/test/custom-elements/samples/ce-options-valid/main.svelte b/test/custom-elements/samples/ce-options-valid/main.svelte new file mode 100644 index 000000000000..b03ee6a99dff --- /dev/null +++ b/test/custom-elements/samples/ce-options-valid/main.svelte @@ -0,0 +1,14 @@ + + + + +

Hello {name}!

diff --git a/test/custom-elements/samples/ce-options-valid/test.js b/test/custom-elements/samples/ce-options-valid/test.js new file mode 100644 index 000000000000..9fa19e53a25c --- /dev/null +++ b/test/custom-elements/samples/ce-options-valid/test.js @@ -0,0 +1,13 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + await tick(); + + const el = target.querySelector('custom-element'); + const h1 = el.shadowRoot.querySelector('h1'); + + assert.equal(h1.textContent, 'Hello world!'); +} diff --git a/test/custom-elements/samples/custom-method/main.svelte b/test/custom-elements/samples/custom-method/main.svelte index 6a99cd7ed621..a1de98015a98 100644 --- a/test/custom-elements/samples/custom-method/main.svelte +++ b/test/custom-elements/samples/custom-method/main.svelte @@ -1,4 +1,4 @@ - + + + diff --git a/test/custom-elements/samples/events/test.js b/test/custom-elements/samples/events/test.js new file mode 100644 index 000000000000..ba87fe8f5790 --- /dev/null +++ b/test/custom-elements/samples/events/test.js @@ -0,0 +1,35 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + const el = target.querySelector('custom-element'); + + const events = []; + const custom_before = () => { + events.push('before'); + }; + const click_before = () => { + events.push('click_before'); + }; + el.addEventListener('custom', custom_before); + el.addEventListener('click', click_before); + + await tick(); + + el.addEventListener('custom', e => { + events.push(e.detail); + }); + el.addEventListener('click', () => { + events.push('click'); + }); + + el.shadowRoot.querySelector('button').click(); + assert.deepEqual(events, ['before', 'foo', 'click_before', 'click']); + + el.removeEventListener('custom', custom_before); + el.removeEventListener('click', click_before); + el.shadowRoot.querySelector('button').click(); + assert.deepEqual(events, ['before', 'foo', 'click_before', 'click', 'foo', 'click']); +} diff --git a/test/custom-elements/samples/extended-builtin/_config.js b/test/custom-elements/samples/extended-builtin/_config.js index 932460335fe1..86198f1ff8dc 100644 --- a/test/custom-elements/samples/extended-builtin/_config.js +++ b/test/custom-elements/samples/extended-builtin/_config.js @@ -2,14 +2,14 @@ export default { warnings: [{ code: 'avoid-is', message: "The 'is' attribute is not supported cross-browser and should be avoided", - pos: 98, + pos: 109, start: { - character: 98, + character: 109, column: 8, line: 7 }, end: { - character: 116, + character: 127, column: 26, line: 7 } diff --git a/test/custom-elements/samples/extended-builtin/main.svelte b/test/custom-elements/samples/extended-builtin/main.svelte index 3f1b59db53b9..5bf6c056775e 100644 --- a/test/custom-elements/samples/extended-builtin/main.svelte +++ b/test/custom-elements/samples/extended-builtin/main.svelte @@ -1,7 +1,7 @@ - + - \ No newline at end of file + diff --git a/test/custom-elements/samples/extended-builtin/test.js b/test/custom-elements/samples/extended-builtin/test.js index a2f253e5d477..ba5d27ea6d70 100644 --- a/test/custom-elements/samples/extended-builtin/test.js +++ b/test/custom-elements/samples/extended-builtin/test.js @@ -1,11 +1,10 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; - -export default function (target) { - new CustomElement({ - target - }); +import { tick } from 'svelte'; +import './main.svelte'; +export default async function (target) { + target.innerHTML = ''; + await tick(); assert.equal(target.innerHTML, ''); const el = target.querySelector('custom-element'); diff --git a/test/custom-elements/samples/html-slots/main.svelte b/test/custom-elements/samples/html-slots/main.svelte index 91f1fb800e85..a894db5f7678 100644 --- a/test/custom-elements/samples/html-slots/main.svelte +++ b/test/custom-elements/samples/html-slots/main.svelte @@ -1,11 +1,11 @@ - +

default fallback content

- +

foo fallback content

diff --git a/test/custom-elements/samples/html-slots/test.js b/test/custom-elements/samples/html-slots/test.js index 06d18d9944d1..c8e09347dba9 100644 --- a/test/custom-elements/samples/html-slots/test.js +++ b/test/custom-elements/samples/html-slots/test.js @@ -1,11 +1,13 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = ` slotted `; + await tick(); const el = target.querySelector('custom-element'); @@ -13,5 +15,5 @@ export default function (target) { const [slot0, slot1] = div.children; assert.equal(slot0.assignedNodes()[1], target.querySelector('strong')); - assert.equal(slot1.assignedNodes().length, 0); + assert.equal(slot1.innerHTML, 'foo fallback content'); } diff --git a/test/custom-elements/samples/html/main.svelte b/test/custom-elements/samples/html/main.svelte index 0931535a1809..fba08ac2698f 100644 --- a/test/custom-elements/samples/html/main.svelte +++ b/test/custom-elements/samples/html/main.svelte @@ -1,4 +1,4 @@ - + - - diff --git a/test/custom-elements/samples/nested.skip/main.svelte b/test/custom-elements/samples/nested.skip/main.svelte deleted file mode 100644 index cb26008061a7..000000000000 --- a/test/custom-elements/samples/nested.skip/main.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - - - -

clicked {count} times

diff --git a/test/custom-elements/samples/nested.skip/test.js b/test/custom-elements/samples/nested.skip/test.js deleted file mode 100644 index 09edc38f54b7..000000000000 --- a/test/custom-elements/samples/nested.skip/test.js +++ /dev/null @@ -1,17 +0,0 @@ -import * as assert from 'assert'; -import './main.svelte'; - -export default async function (target) { - target.innerHTML = ''; - const el = target.querySelector('my-app'); - const counter = el.shadowRoot.querySelector('my-counter'); - const button = counter.shadowRoot.querySelector('button'); - - assert.equal(counter.count, 0); - assert.equal(counter.shadowRoot.innerHTML, ''); - - await button.dispatchEvent(new MouseEvent('click')); - - assert.equal(counter.count, 1); - assert.equal(counter.shadowRoot.innerHTML, ''); -} diff --git a/test/custom-elements/samples/nested/Counter.svelte b/test/custom-elements/samples/nested/Counter.svelte new file mode 100644 index 000000000000..406be1a8e129 --- /dev/null +++ b/test/custom-elements/samples/nested/Counter.svelte @@ -0,0 +1,19 @@ + + + + + + +

Context {context}

+ + diff --git a/test/custom-elements/samples/nested/main.svelte b/test/custom-elements/samples/nested/main.svelte new file mode 100644 index 000000000000..b58be56768d0 --- /dev/null +++ b/test/custom-elements/samples/nested/main.svelte @@ -0,0 +1,16 @@ + + + + + + slot {count} + +

clicked {count} times

diff --git a/test/custom-elements/samples/nested/test.js b/test/custom-elements/samples/nested/test.js new file mode 100644 index 000000000000..1dab15ba19b4 --- /dev/null +++ b/test/custom-elements/samples/nested/test.js @@ -0,0 +1,24 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + await tick(); + const el = target.querySelector('my-app'); + const button = el.shadowRoot.querySelector('button'); + const span = el.shadowRoot.querySelector('span'); + const p = el.shadowRoot.querySelector('p'); + + assert.equal(el.counter.count, 0); + assert.equal(button.innerHTML, 'count: 0'); + assert.equal(span.innerHTML, 'slot 0'); + assert.equal(p.innerHTML, 'Context works'); + assert.equal(getComputedStyle(button).color, 'rgb(255, 0, 0)'); + + await button.dispatchEvent(new MouseEvent('click')); + + assert.equal(el.counter.count, 1); + assert.equal(button.innerHTML, 'count: 1'); + assert.equal(span.innerHTML, 'slot 1'); +} diff --git a/test/custom-elements/samples/new-styled/main.svelte b/test/custom-elements/samples/new-styled/main.svelte index e69c6e05b1e2..b70eb27030bc 100644 --- a/test/custom-elements/samples/new-styled/main.svelte +++ b/test/custom-elements/samples/new-styled/main.svelte @@ -1,4 +1,4 @@ - +

styled

diff --git a/test/custom-elements/samples/new-styled/test.js b/test/custom-elements/samples/new-styled/test.js index 72c2cecd10cc..bf7abb449e6f 100644 --- a/test/custom-elements/samples/new-styled/test.js +++ b/test/custom-elements/samples/new-styled/test.js @@ -1,12 +1,11 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; +import { tick } from 'svelte'; +import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = '

unstyled

'; - - new CustomElement({ - target - }); + target.appendChild(document.createElement('custom-element')); + await tick(); const unstyled = target.querySelector('p'); const styled = target.querySelector('custom-element').shadowRoot.querySelector('p'); diff --git a/test/custom-elements/samples/new/main.svelte b/test/custom-elements/samples/new/main.svelte deleted file mode 100644 index 0931535a1809..000000000000 --- a/test/custom-elements/samples/new/main.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - - -

Hello {name}!

diff --git a/test/custom-elements/samples/new/test.js b/test/custom-elements/samples/new/test.js deleted file mode 100644 index 88ba69ab69b7..000000000000 --- a/test/custom-elements/samples/new/test.js +++ /dev/null @@ -1,18 +0,0 @@ -import * as assert from 'assert'; -import CustomElement from './main.svelte'; - -export default function (target) { - new CustomElement({ - target, - props: { - name: 'world' - } - }); - - assert.equal(target.innerHTML, ''); - - const el = target.querySelector('custom-element'); - const h1 = el.shadowRoot.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello world!'); -} diff --git a/test/custom-elements/samples/no-missing-prop-warnings/main.svelte b/test/custom-elements/samples/no-missing-prop-warnings/main.svelte index 3ea205e3f3a1..31076dc35704 100644 --- a/test/custom-elements/samples/no-missing-prop-warnings/main.svelte +++ b/test/custom-elements/samples/no-missing-prop-warnings/main.svelte @@ -1,4 +1,4 @@ - + + +

Hello {name}!

+ + diff --git a/test/custom-elements/samples/no-shadow-dom/test.js b/test/custom-elements/samples/no-shadow-dom/test.js new file mode 100644 index 000000000000..29abe702aed9 --- /dev/null +++ b/test/custom-elements/samples/no-shadow-dom/test.js @@ -0,0 +1,16 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + await tick(); + + const el = target.querySelector('custom-element'); + const h1 = el.querySelector('h1'); + + assert.equal(el.name, 'world'); + assert.equal(el.shadowRoot, null); + assert.equal(h1.innerHTML, 'Hello world!'); + assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)'); +} diff --git a/test/custom-elements/samples/no-svelte-options/_config.js b/test/custom-elements/samples/no-svelte-options/_config.js deleted file mode 100644 index 98273f767f17..000000000000 --- a/test/custom-elements/samples/no-svelte-options/_config.js +++ /dev/null @@ -1,17 +0,0 @@ -export default { - warnings: [{ - code: 'custom-element-no-tag', - message: "No custom element 'tag' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. . To hide this warning, use ", - pos: 0, - start: { - character: 0, - column: 0, - line: 1 - }, - end: { - character: 0, - column: 0, - line: 1 - } - }] -}; diff --git a/test/custom-elements/samples/no-svelte-options/main.svelte b/test/custom-elements/samples/no-svelte-options/main.svelte deleted file mode 100644 index 538dc970e935..000000000000 --- a/test/custom-elements/samples/no-svelte-options/main.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -

Hello {name}!

diff --git a/test/custom-elements/samples/no-svelte-options/test.js b/test/custom-elements/samples/no-svelte-options/test.js deleted file mode 100644 index e6ce82d1a4ee..000000000000 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as assert from 'assert'; -import CustomElement from './main.svelte'; - -export default function (target) { - customElements.define('no-tag', CustomElement); - target.innerHTML = ''; - - const el = target.querySelector('no-tag'); - const h1 = el.shadowRoot.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello world!'); -} diff --git a/test/custom-elements/samples/no-tag-warning/_config.js b/test/custom-elements/samples/no-tag-warning/_config.js deleted file mode 100644 index fb476a7b5b4f..000000000000 --- a/test/custom-elements/samples/no-tag-warning/_config.js +++ /dev/null @@ -1,17 +0,0 @@ -export default { - warnings: [{ - code: 'custom-element-no-tag', - message: "No custom element 'tag' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. . To hide this warning, use ", - pos: 0, - start: { - character: 0, - column: 0, - line: 1 - }, - end: { - character: 18, - column: 18, - line: 1 - } - }] -}; diff --git a/test/custom-elements/samples/no-tag-warning/main.svelte b/test/custom-elements/samples/no-tag-warning/main.svelte deleted file mode 100644 index 4f7cdc52caa1..000000000000 --- a/test/custom-elements/samples/no-tag-warning/main.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - - -

Hello {name}!

diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js deleted file mode 100644 index e6ce82d1a4ee..000000000000 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as assert from 'assert'; -import CustomElement from './main.svelte'; - -export default function (target) { - customElements.define('no-tag', CustomElement); - target.innerHTML = ''; - - const el = target.querySelector('no-tag'); - const h1 = el.shadowRoot.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello world!'); -} diff --git a/test/custom-elements/samples/no-tag/main.svelte b/test/custom-elements/samples/no-tag/main.svelte index 031bd936942d..538dc970e935 100644 --- a/test/custom-elements/samples/no-tag/main.svelte +++ b/test/custom-elements/samples/no-tag/main.svelte @@ -1,5 +1,3 @@ - - diff --git a/test/custom-elements/samples/no-tag/test.js b/test/custom-elements/samples/no-tag/test.js index e6ce82d1a4ee..b933d24c39be 100644 --- a/test/custom-elements/samples/no-tag/test.js +++ b/test/custom-elements/samples/no-tag/test.js @@ -1,9 +1,11 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import CustomElement from './main.svelte'; -export default function (target) { - customElements.define('no-tag', CustomElement); +export default async function (target) { + customElements.define('no-tag', CustomElement.element); target.innerHTML = ''; + await tick(); const el = target.querySelector('no-tag'); const h1 = el.shadowRoot.querySelector('h1'); diff --git a/test/custom-elements/samples/oncreate/main.svelte b/test/custom-elements/samples/oncreate/main.svelte index 23819e660f15..f31603606989 100644 --- a/test/custom-elements/samples/oncreate/main.svelte +++ b/test/custom-elements/samples/oncreate/main.svelte @@ -1,14 +1,14 @@ - + diff --git a/test/custom-elements/samples/oncreate/test.js b/test/custom-elements/samples/oncreate/test.js index f451979976c6..d377efe156f3 100644 --- a/test/custom-elements/samples/oncreate/test.js +++ b/test/custom-elements/samples/oncreate/test.js @@ -1,10 +1,14 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = ''; + await tick(); const el = target.querySelector('my-app'); + await tick(); + assert.ok(el.wasCreated); assert.ok(el.propsInitialized); } diff --git a/test/custom-elements/samples/ondestroy/main.svelte b/test/custom-elements/samples/ondestroy/main.svelte index aa945ca60202..b4a91da7fb84 100644 --- a/test/custom-elements/samples/ondestroy/main.svelte +++ b/test/custom-elements/samples/ondestroy/main.svelte @@ -1,8 +1,8 @@ - + -
+
diff --git a/test/custom-elements/samples/ondestroy/test.js b/test/custom-elements/samples/ondestroy/test.js index 61375bfa966a..62ec07a419be 100644 --- a/test/custom-elements/samples/ondestroy/test.js +++ b/test/custom-elements/samples/ondestroy/test.js @@ -1,11 +1,15 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = ''; + await tick(); const el = target.querySelector('my-app'); target.removeChild(el); + await tick(); + assert.ok(target.dataset.onMountDestroyed); - assert.equal(target.dataset.destroyed, undefined); + assert.ok(target.dataset.destroyed); } diff --git a/test/custom-elements/samples/props/main.svelte b/test/custom-elements/samples/props/main.svelte index cf47b436b590..5dbedd77af1c 100644 --- a/test/custom-elements/samples/props/main.svelte +++ b/test/custom-elements/samples/props/main.svelte @@ -1,9 +1,9 @@ - + diff --git a/test/custom-elements/samples/props/my-widget.svelte b/test/custom-elements/samples/props/my-widget.svelte index 970acf84b21b..3fb3d95c5882 100644 --- a/test/custom-elements/samples/props/my-widget.svelte +++ b/test/custom-elements/samples/props/my-widget.svelte @@ -1,4 +1,4 @@ - +

{items.length} items

-

{items.join(', ')}

-

{flag1 ? 'flagged (dynamic attribute)' : 'not flagged'}

-

{flag2 ? 'flagged (static attribute)' : 'not flagged'}

+

{items.join(", ")}

+

{flag1 ? "flagged (dynamic attribute)" : "not flagged"}

+

{flag2 ? "flagged (static attribute)" : "not flagged"}

diff --git a/test/custom-elements/samples/props/test.js b/test/custom-elements/samples/props/test.js index 41ca77d29d02..1f50c9be8831 100644 --- a/test/custom-elements/samples/props/test.js +++ b/test/custom-elements/samples/props/test.js @@ -1,10 +1,11 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; +import { tick } from 'svelte'; +import './main.svelte'; -export default function (target) { - new CustomElement({ - target - }); +export default async function (target) { + target.innerHTML = ''; + await tick(); + await tick(); assert.equal(target.innerHTML, ''); @@ -20,6 +21,7 @@ export default function (target) { el.items = ['d', 'e', 'f', 'g', 'h']; el.flagged = true; + await tick(); assert.equal(p1.textContent, '5 items'); assert.equal(p2.textContent, 'd, e, f, g, h'); diff --git a/test/custom-elements/samples/reflect-attributes/main.svelte b/test/custom-elements/samples/reflect-attributes/main.svelte new file mode 100644 index 000000000000..0e36f0143ce1 --- /dev/null +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -0,0 +1,25 @@ + + + + +
hi
+

hi

+ + + diff --git a/test/custom-elements/samples/reflect-attributes/my-widget.svelte b/test/custom-elements/samples/reflect-attributes/my-widget.svelte new file mode 100644 index 000000000000..344501e04811 --- /dev/null +++ b/test/custom-elements/samples/reflect-attributes/my-widget.svelte @@ -0,0 +1,23 @@ + + + + +
hi
+

hi

+ + diff --git a/test/custom-elements/samples/reflect-attributes/test.js b/test/custom-elements/samples/reflect-attributes/test.js new file mode 100644 index 000000000000..dfa925403dcd --- /dev/null +++ b/test/custom-elements/samples/reflect-attributes/test.js @@ -0,0 +1,22 @@ +import * as assert from 'assert'; +import { tick } from 'svelte'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + await tick(); + await tick(); + const ceRoot = target.querySelector('custom-element').shadowRoot; + const div = ceRoot.querySelector('div'); + const p = ceRoot.querySelector('p'); + + assert.equal(getComputedStyle(div).color, 'rgb(255, 0, 0)'); + assert.equal(getComputedStyle(p).color, 'rgb(255, 255, 255)'); + + const innerRoot = ceRoot.querySelector('my-widget').shadowRoot; + const innerDiv = innerRoot.querySelector('div'); + const innerP = innerRoot.querySelector('p'); + + assert.equal(getComputedStyle(innerDiv).color, 'rgb(255, 0, 0)'); + assert.equal(getComputedStyle(innerP).color, 'rgb(255, 255, 255)'); +} diff --git a/test/helpers.js b/test/helpers.js index cfbebf93849b..de4d519c7f45 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -350,8 +350,7 @@ export async function executeBrowserTest(browser, launchPuppeteer, additionalAss const page = await browser.newPage(); page.on('console', (type) => { - // @ts-ignore -- TODO: Fix type - console[type._type](type._text); + console[type.type()](type.text()); }); page.on('error', error => { diff --git a/test/js/samples/css-shadow-dom-keyframes/expected.js b/test/js/samples/css-shadow-dom-keyframes/expected.js index ba7ca9a6679b..98f5120c9b64 100644 --- a/test/js/samples/css-shadow-dom-keyframes/expected.js +++ b/test/js/samples/css-shadow-dom-keyframes/expected.js @@ -1,7 +1,9 @@ /* generated by Svelte vX.Y.Z */ import { - SvelteElement, - attribute_to_object, + SvelteComponent, + append_styles, + attr, + create_custom_element, detach, element, init, @@ -10,6 +12,10 @@ import { safe_not_equal } from "svelte/internal"; +function add_css(target) { + append_styles(target, "svelte-10axo0s", "div.svelte-10axo0s{animation:svelte-10axo0s-foo 1s}@keyframes svelte-10axo0s-foo{0%{opacity:0}100%{opacity:1}}"); +} + function create_fragment(ctx) { let div; @@ -17,7 +23,7 @@ function create_fragment(ctx) { c() { div = element("div"); div.textContent = "fades in"; - this.c = noop; + attr(div, "class", "svelte-10axo0s"); }, m(target, anchor) { insert(target, div, anchor); @@ -31,34 +37,12 @@ function create_fragment(ctx) { }; } -class Component extends SvelteElement { +class Component extends SvelteComponent { constructor(options) { super(); - const style = document.createElement('style'); - style.textContent = `div{animation:foo 1s}@keyframes foo{0%{opacity:0}100%{opacity:1}}`; - this.shadowRoot.appendChild(style); - - init( - this, - { - target: this.shadowRoot, - props: attribute_to_object(this.attributes), - customElement: true - }, - null, - create_fragment, - safe_not_equal, - {}, - null - ); - - if (options) { - if (options.target) { - insert(options.target, this, options.anchor); - } - } + init(this, options, null, create_fragment, safe_not_equal, {}, add_css); } } -customElements.define("custom-element", Component); -export default Component; +customElements.define("custom-element", create_custom_element(Component, {}, [], [], true)); +export default Component; \ No newline at end of file diff --git a/test/js/samples/css-shadow-dom-keyframes/input.svelte b/test/js/samples/css-shadow-dom-keyframes/input.svelte index bf0aebaa9a43..3aca83d658ca 100644 --- a/test/js/samples/css-shadow-dom-keyframes/input.svelte +++ b/test/js/samples/css-shadow-dom-keyframes/input.svelte @@ -1,4 +1,4 @@ - +
fades in
@@ -8,7 +8,11 @@ } @keyframes foo { - 0% { opacity: 0; } - 100% { opacity: 1; } + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } } diff --git a/test/tsconfig.json b/test/tsconfig.json index 82eaf0245e38..83eecc51dc2a 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "include": ["."], + "exclude": ["./**/_output/**/*"], "compilerOptions": { "allowJs": true, diff --git a/test/validator/samples/missing-custom-element-compile-options/input.svelte b/test/validator/samples/missing-custom-element-compile-options/input.svelte index 94ecce3ef64e..2313d81896d7 100644 --- a/test/validator/samples/missing-custom-element-compile-options/input.svelte +++ b/test/validator/samples/missing-custom-element-compile-options/input.svelte @@ -1 +1 @@ - \ No newline at end of file + diff --git a/test/validator/samples/missing-custom-element-compile-options/warnings.json b/test/validator/samples/missing-custom-element-compile-options/warnings.json index 243623f51c43..f0cef37936ce 100644 --- a/test/validator/samples/missing-custom-element-compile-options/warnings.json +++ b/test/validator/samples/missing-custom-element-compile-options/warnings.json @@ -2,10 +2,10 @@ { "code": "missing-custom-element-compile-options", "end": { - "column": 36, + "column": 46, "line": 1 }, - "message": "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?", + "message": "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?", "start": { "column": 16, "line": 1 diff --git a/test/validator/samples/tag-custom-element-options-missing/input.svelte b/test/validator/samples/tag-custom-element-options-missing/input.svelte index f5f5d7427059..4159cd71e0ef 100644 --- a/test/validator/samples/tag-custom-element-options-missing/input.svelte +++ b/test/validator/samples/tag-custom-element-options-missing/input.svelte @@ -1 +1 @@ - + diff --git a/test/validator/samples/tag-custom-element-options-missing/warnings.json b/test/validator/samples/tag-custom-element-options-missing/warnings.json index a1927f41dfdc..134c8b929d17 100644 --- a/test/validator/samples/tag-custom-element-options-missing/warnings.json +++ b/test/validator/samples/tag-custom-element-options-missing/warnings.json @@ -1,12 +1,12 @@ [{ "code": "missing-custom-element-compile-options", - "message": "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?", + "message": "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?", "start": { "line": 1, "column": 16 }, "end": { "line": 1, - "column": 36 + "column": 46 } }] diff --git a/test/validator/samples/tag-custom-element-options-true/input.svelte b/test/validator/samples/tag-custom-element-options-true/input.svelte index f5f5d7427059..4159cd71e0ef 100644 --- a/test/validator/samples/tag-custom-element-options-true/input.svelte +++ b/test/validator/samples/tag-custom-element-options-true/input.svelte @@ -1 +1 @@ - + diff --git a/test/validator/samples/tag-invalid/errors.json b/test/validator/samples/tag-invalid/errors.json index f3aef07feac7..2ba999cb31f2 100644 --- a/test/validator/samples/tag-invalid/errors.json +++ b/test/validator/samples/tag-invalid/errors.json @@ -7,6 +7,6 @@ }, "end": { "line": 1, - "column": 29 + "column": 39 } }] diff --git a/test/validator/samples/tag-invalid/input.svelte b/test/validator/samples/tag-invalid/input.svelte index 330552f726a0..7311283eca37 100644 --- a/test/validator/samples/tag-invalid/input.svelte +++ b/test/validator/samples/tag-invalid/input.svelte @@ -1 +1 @@ - +