Skip to content

Commit d083f8a

Browse files
authored
feat: custom elements rework (#8457)
This is an overhaul of custom elements in Svelte. Instead of compiling to a custom element class, the Svelte component class is mostly preserved as-is. Instead a wrapper is introduced which wraps a Svelte component constructor and returns a HTML element constructor. This has a couple of advantages: - component can be used both as a custom element as well as a regular component. This allows creating one wrapper custom element and using regular Svelte components inside. Fixes #3594, fixes #3128, fixes #4274, fixes #5486, fixes #3422, fixes #2969, helps with sveltejs/kit#4502 - all components are compiled with injected styles (inlined through Javascript), fixes #4274 - the wrapper instantiates the component in `connectedCallback` and disconnects it in `disconnectedCallback` (but only after one tick, because this could be a element move). Mount/destroy works as expected inside, fixes #5989, fixes #8191 - the wrapper forwards `addEventListener` calls to `component.$on`, which allows to listen to custom events, fixes #3119, closes #4142 - some things are hard to auto-configure, like attribute hyphen preferences or whether or not setting a property should reflect back to the attribute. This is why `<svelte:options customElement={..}>` can also take an object to modify such aspects. This option allows to specify whether setting a prop should be reflected back to the attribute (default `false`), what to use when converting the property to the attribute value and vice versa (through `type`, default `String`, or when `export let prop = false` then `Boolean`), and what the corresponding attribute for the property is (`attribute`, default lowercased prop name). These options are heavily inspired by lit: https://lit.dev/docs/components/properties. Closes #7638, fixes #5705 - adds a `shadowdom` option to control whether or not encapsulate the custom element. Closes #4330, closes #1748 Breaking changes: - Wrapped Svelte component now stays as a regular Svelte component (invokeing it like before with `new Component({ target: ..})` won't create a custom element). Its custom element constructor is now a static property named `element` on the class (`Component.element`) and should be regularly invoked through setting it in the html. - The timing of mount/destroy/update is different. Mount/destroy/updating a prop all happen after a tick, so `shadowRoot.innerHTML` won't immediately reflect the change (Lit does this too). If you rely on it, you need to await a promise
1 parent 0677d89 commit d083f8a

File tree

86 files changed

+969
-449
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+969
-449
lines changed

.eslintrc.js

+3
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@ module.exports = {
1010
'estree'
1111
],
1212
'svelte3/compiler': require('./compiler')
13+
},
14+
rules: {
15+
'@typescript-eslint/no-non-null-assertion': 'off'
1316
}
1417
};

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
99
* **breaking** Stricter types for `Action` and `ActionReturn` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
1010
* **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))
11+
* **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))
1112
* **breaking** Deprecate `SvelteComponentTyped`, use `SvelteComponent` instead ([#8512](https://github.com/sveltejs/svelte/pull/8512))
1213
* Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391))
1314
* Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251))

elements/index.d.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1597,7 +1597,17 @@ export interface SvelteHTMLElements {
15971597
'svelte:document': HTMLAttributes<Document>;
15981598
'svelte:body': HTMLAttributes<HTMLElement>;
15991599
'svelte:fragment': { slot?: string };
1600-
'svelte:options': { [name: string]: any };
1600+
'svelte:options': {
1601+
customElement?: string | undefined | {
1602+
tag: string;
1603+
shadow?: 'open' | 'none' | undefined;
1604+
props?: Record<string, { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }> | undefined;
1605+
};
1606+
immutable?: boolean | undefined;
1607+
accessors?: boolean | undefined;
1608+
namespace?: string | undefined;
1609+
[name: string]: any
1610+
};
16011611
'svelte:head': { [name: string]: any };
16021612

16031613
[name: string]: { [name: string]: any };

rollup.config.mjs

+4-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ const is_publish = !!process.env.PUBLISH;
1515
const ts_plugin = is_publish
1616
? typescript({
1717
typescript: require('typescript'),
18+
paths: {
19+
'svelte/*': ['./src/runtime/*']
20+
}
1821
})
1922
: sucrase({
20-
transforms: ['typescript'],
23+
transforms: ['typescript']
2124
});
2225

2326
fs.writeFileSync(

site/content/docs/03-template-syntax.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1825,10 +1825,10 @@ The `<svelte:options>` element provides a place to specify per-component compile
18251825
* `accessors={true}` — adds getters and setters for the component's props
18261826
* `accessors={false}` — the default
18271827
* `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
1828-
* `tag="..."` — the name to use when compiling this component as a custom element
1828+
* `customElement="..."` — the name to use when compiling this component as a custom element
18291829

18301830
```sv
1831-
<svelte:options tag="my-custom-element"/>
1831+
<svelte:options customElement="my-custom-element"/>
18321832
```
18331833

18341834
### `<svelte:fragment>`

site/content/docs/04-run-time.md

+32-5
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,7 @@ app.count += 1;
11181118
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 `<svelte:options>` [element](/docs#template-syntax-svelte-options).
11191119

11201120
```sv
1121-
<svelte:options tag="my-element" />
1121+
<svelte:options customElement="my-element" />
11221122
11231123
<script>
11241124
export let name = 'world';
@@ -1130,12 +1130,12 @@ Svelte components can also be compiled to custom elements (aka web components) u
11301130

11311131
---
11321132

1133-
Alternatively, use `tag={null}` to indicate that the consumer of the custom element should name it.
1133+
You can leave out the tag name for any of your inner components which you don't want to expose and use them like regular Svelte components. Consumers of the component can still name it afterwards if needed, using the static `element` property which contains the custom element constructor and which is available when the `customElement` compiler option is `true`.
11341134

11351135
```js
11361136
import MyElement from './MyElement.svelte';
11371137

1138-
customElements.define('my-element', MyElement);
1138+
customElements.define('my-element', MyElement.element);
11391139
```
11401140

11411141
---
@@ -1166,15 +1166,42 @@ console.log(el.name);
11661166
el.name = 'everybody';
11671167
```
11681168

1169+
---
1170+
1171+
When constructing a custom element, you can tailor several aspects by defining `customElement` as an object within `<svelte:options>`. This object comprises a mandatory `tag` property for the custom element's name, an optional `shadow` property that can be set to `"none"` to forgo shadow root creation, and a `props` option, which offers the following settings:
1172+
1173+
- `attribute: string`: To update a custom element's prop, you have two alternatives: either set the property on the custom element's reference as illustrated above or use an HTML attribute. For the latter, the default attribute name is the lowercase property name. Modify this by assigning `attribute: "<desired name>"`.
1174+
- `reflect: boolean`: By default, updated prop values do not reflect back to the DOM. To enable this behavior, set `reflect: true`.
1175+
- `type: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object'`: While converting an attribute value to a prop value and reflecting it back, the prop value is assumed to be a `String` by default. This may not always be accurate. For instance, for a number type, define it using `type: "Number"`
1176+
1177+
```svelte
1178+
<svelte:options
1179+
customElement={{
1180+
tag: "custom-element",
1181+
shadow: "none",
1182+
props: {
1183+
name: { reflect: true, type: "Number", attribute: "element-index" },
1184+
},
1185+
}}
1186+
/>
1187+
1188+
<script>
1189+
export let elementIndex;
1190+
</script>
1191+
1192+
...
1193+
```
1194+
11691195
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:
11701196

1171-
* 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
1197+
* 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
11721198
* Instead of being extracted out as a separate .css file, styles are inlined into the component as a JavaScript string
11731199
* Custom elements are not generally suitable for server-side rendering, as the shadow DOM is invisible until JavaScript loads
11741200
* 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 `<slot>` element is inside an `{#if ...}` block. Similarly, including a `<slot>` in an `{#each ...}` block will not cause the slotted content to be rendered multiple times
1175-
* The `let:` directive has no effect
1201+
* 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
11761202
* Polyfills are required to support older browsers
11771203

1204+
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.
11781205

11791206

11801207
### Server-side component API

site/content/tutorial/16-special-elements/09-svelte-options/text.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@ The options that can be set here are:
2525
* `accessors={true}` — adds getters and setters for the component's props
2626
* `accessors={false}` — the default
2727
* `namespace="..."` — the namespace where this component will be used, most commonly `"svg"`
28-
* `tag="..."` — the name to use when compiling this component as a custom element
28+
* `customElement="..."` — the name to use when compiling this component as a custom element
2929

3030
Consult the [API reference](/docs) for more information on these options.

src/compiler/compile/Component.ts

+97-23
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import Stylesheet from './css/Stylesheet';
1616
import { test } from '../config';
1717
import Fragment from './nodes/Fragment';
1818
import internal_exports from './internal_exports';
19-
import { Ast, CompileOptions, Var, Warning, CssResult } from '../interfaces';
19+
import { Ast, CompileOptions, Var, Warning, CssResult, Attribute } from '../interfaces';
2020
import error from '../utils/error';
2121
import get_code_frame from '../utils/get_code_frame';
2222
import flatten_reference from './utils/flatten_reference';
@@ -26,7 +26,7 @@ import TemplateScope from './nodes/shared/TemplateScope';
2626
import fuzzymatch from '../utils/fuzzymatch';
2727
import get_object from './utils/get_object';
2828
import Slot from './nodes/Slot';
29-
import { Node, ImportDeclaration, ExportNamedDeclaration, Identifier, ExpressionStatement, AssignmentExpression, Literal, Property, RestElement, ExportDefaultDeclaration, ExportAllDeclaration, FunctionDeclaration, FunctionExpression } from 'estree';
29+
import { Node, ImportDeclaration, ExportNamedDeclaration, Identifier, ExpressionStatement, AssignmentExpression, Literal, Property, RestElement, ExportDefaultDeclaration, ExportAllDeclaration, FunctionDeclaration, FunctionExpression, ObjectExpression } from 'estree';
3030
import add_to_set from './utils/add_to_set';
3131
import check_graph_for_cycles from './utils/check_graph_for_cycles';
3232
import { print, b } from 'code-red';
@@ -42,10 +42,14 @@ import Tag from './nodes/shared/Tag';
4242

4343
interface ComponentOptions {
4444
namespace?: string;
45-
tag?: string;
4645
immutable?: boolean;
4746
accessors?: boolean;
4847
preserveWhitespace?: boolean;
48+
customElement?: {
49+
tag: string | null;
50+
shadow?: 'open' | 'none';
51+
props?: Record<string, { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }>;
52+
};
4953
}
5054

5155
const regex_leading_directory_separator = /^[/\\]/;
@@ -167,16 +171,7 @@ export default class Component {
167171
this.component_options.namespace;
168172

169173
if (compile_options.customElement) {
170-
if (
171-
this.component_options.tag === undefined &&
172-
compile_options.tag === undefined
173-
) {
174-
const svelteOptions = ast.html.children.find(
175-
child => child.name === 'svelte:options'
176-
) || { start: 0, end: 0 };
177-
this.warn(svelteOptions, compiler_warnings.custom_element_no_tag);
178-
}
179-
this.tag = this.component_options.tag || compile_options.tag;
174+
this.tag = this.component_options.customElement?.tag || compile_options.tag || this.name.name;
180175
} else {
181176
this.tag = this.name.name;
182177
}
@@ -195,7 +190,7 @@ export default class Component {
195190
this.pop_ignores();
196191

197192
this.elements.forEach(element => this.stylesheet.apply(element));
198-
if (!compile_options.customElement) this.stylesheet.reify();
193+
this.stylesheet.reify();
199194
this.stylesheet.warn_on_unused_selectors(this);
200195
}
201196

@@ -547,6 +542,9 @@ export default class Component {
547542
extract_names(declarator.id).forEach(name => {
548543
const variable = this.var_lookup.get(name);
549544
variable.export_name = name;
545+
if (declarator.init?.type === 'Literal' && typeof declarator.init.value === 'boolean') {
546+
variable.is_boolean = true;
547+
}
550548
if (!module_script && variable.writable && !(variable.referenced || variable.referenced_from_script || variable.subscribable)) {
551549
this.warn(declarator as any, compiler_warnings.unused_export_let(this.name.name, name));
552550
}
@@ -1560,23 +1558,99 @@ function process_component_options(component: Component, nodes) {
15601558
if (attribute.type === 'Attribute') {
15611559
const { name } = attribute;
15621560

1561+
function parse_tag(attribute: Attribute, tag: string) {
1562+
if (typeof tag !== 'string' && tag !== null) {
1563+
return component.error(attribute, compiler_errors.invalid_tag_attribute);
1564+
}
1565+
1566+
if (tag && !regex_valid_tag_name.test(tag)) {
1567+
return component.error(attribute, compiler_errors.invalid_tag_property);
1568+
}
1569+
1570+
if (tag && !component.compile_options.customElement) {
1571+
component.warn(attribute, compiler_warnings.missing_custom_element_compile_options);
1572+
}
1573+
1574+
component_options.customElement = component_options.customElement || {} as any;
1575+
component_options.customElement.tag = tag;
1576+
}
1577+
15631578
switch (name) {
15641579
case 'tag': {
1565-
const tag = get_value(attribute, compiler_errors.invalid_tag_attribute);
1580+
component.warn(attribute, compiler_warnings.tag_option_deprecated);
1581+
parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute));
1582+
break;
1583+
}
15661584

1567-
if (typeof tag !== 'string' && tag !== null) {
1568-
return component.error(attribute, compiler_errors.invalid_tag_attribute);
1585+
case 'customElement': {
1586+
component_options.customElement = component_options.customElement || {} as any;
1587+
1588+
const { value } = attribute;
1589+
1590+
if (value[0].type === 'MustacheTag' && value[0].expression?.value === null) {
1591+
component_options.customElement.tag = null;
1592+
break;
1593+
} else if (value[0].type === 'Text') {
1594+
parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute));
1595+
break;
1596+
} else if (value[0].expression.type !== 'ObjectExpression') {
1597+
return component.error(attribute, compiler_errors.invalid_customElement_attribute);
15691598
}
15701599

1571-
if (tag && !regex_valid_tag_name.test(tag)) {
1572-
return component.error(attribute, compiler_errors.invalid_tag_property);
1600+
const tag = value[0].expression.properties.find(
1601+
(prop: any) => prop.key.name === 'tag'
1602+
);
1603+
if (tag) {
1604+
parse_tag(tag, tag.value?.value);
1605+
} else {
1606+
return component.error(attribute, compiler_errors.invalid_customElement_attribute);
15731607
}
15741608

1575-
if (tag && !component.compile_options.customElement) {
1576-
component.warn(attribute, compiler_warnings.missing_custom_element_compile_options);
1609+
const props = value[0].expression.properties.find(
1610+
(prop: any) => prop.key.name === 'props'
1611+
);
1612+
if (props) {
1613+
const error = () => component.error(attribute, compiler_errors.invalid_props_attribute);
1614+
if (props.value?.type !== 'ObjectExpression') {
1615+
return error();
1616+
}
1617+
1618+
component_options.customElement.props = {};
1619+
1620+
for (const property of (props.value as ObjectExpression).properties) {
1621+
if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression') {
1622+
return error();
1623+
}
1624+
component_options.customElement.props[property.key.name] = {};
1625+
for (const prop of property.value.properties) {
1626+
if (prop.type !== 'Property' || prop.computed || prop.key.type !== 'Identifier' || prop.value.type !== 'Literal') {
1627+
return error();
1628+
}
1629+
if (['reflect', 'attribute', 'type'].indexOf(prop.key.name) === -1 ||
1630+
prop.key.name === 'type' && ['String', 'Number', 'Boolean', 'Array', 'Object'].indexOf(prop.value.value as string) === -1 ||
1631+
prop.key.name === 'reflect' && typeof prop.value.value !== 'boolean' ||
1632+
prop.key.name === 'attribute' && typeof prop.value.value !== 'string'
1633+
) {
1634+
return error();
1635+
}
1636+
component_options.customElement.props[property.key.name][prop.key.name] = prop.value.value;
1637+
}
1638+
}
1639+
}
1640+
1641+
const shadow = value[0].expression.properties.find(
1642+
(prop: any) => prop.key.name === 'shadow'
1643+
);
1644+
if (shadow) {
1645+
const shadowdom = shadow.value?.value;
1646+
1647+
if (shadowdom !== 'open' && shadowdom !== 'none') {
1648+
return component.error(shadow, compiler_errors.invalid_shadow_attribute);
1649+
}
1650+
1651+
component_options.customElement.shadow = shadowdom;
15771652
}
15781653

1579-
component_options.tag = tag;
15801654
break;
15811655
}
15821656

@@ -1610,7 +1684,7 @@ function process_component_options(component: Component, nodes) {
16101684
}
16111685

16121686
default:
1613-
return component.error(attribute, compiler_errors.invalid_options_attribute_unknown);
1687+
return component.error(attribute, compiler_errors.invalid_options_attribute_unknown(name));
16141688
}
16151689
} else {
16161690
return component.error(attribute, compiler_errors.invalid_options_attribute);

src/compiler/compile/compiler_errors.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,24 @@ export default {
202202
code: 'invalid-tag-property',
203203
message: "tag name must be two or more words joined by the '-' character"
204204
},
205+
invalid_customElement_attribute: {
206+
code: 'invalid-customElement-attribute',
207+
message: "'customElement' must be a string literal defining a valid custom element name or an object of the form " +
208+
"{ tag: string; shadow?: 'open' | 'none'; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } }"
209+
},
205210
invalid_tag_attribute: {
206211
code: 'invalid-tag-attribute',
207212
message: "'tag' must be a string literal"
208213
},
214+
invalid_shadow_attribute: {
215+
code: 'invalid-shadow-attribute',
216+
message: "'shadow' must be either 'open' or 'none'"
217+
},
218+
invalid_props_attribute: {
219+
code: 'invalid-props-attribute',
220+
message: "'props' must be a statically analyzable object literal of the form " +
221+
"'{ [key: string]: { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }'"
222+
},
209223
invalid_namespace_property: (namespace: string, suggestion?: string) => ({
210224
code: 'invalid-namespace-property',
211225
message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '')
@@ -218,10 +232,10 @@ export default {
218232
code: `invalid-${name}-value`,
219233
message: `${name} attribute must be true or false`
220234
}),
221-
invalid_options_attribute_unknown: {
235+
invalid_options_attribute_unknown: (name: string) => ({
222236
code: 'invalid-options-attribute',
223-
message: '<svelte:options> unknown attribute'
224-
},
237+
message: `<svelte:options> unknown attribute '${name}'`
238+
}),
225239
invalid_options_attribute: {
226240
code: 'invalid-options-attribute',
227241
message: "<svelte:options> can only have static 'tag', 'namespace', 'accessors', 'immutable' and 'preserveWhitespace' attributes"

src/compiler/compile/compiler_warnings.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { ARIAPropertyDefinition } from 'aria-query';
66
* @internal
77
*/
88
export default {
9-
custom_element_no_tag: {
10-
code: 'custom-element-no-tag',
11-
message: 'No custom element \'tag\' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. <svelte:options tag="my-thing"/>. To hide this warning, use <svelte:options tag={null}/>'
9+
tag_option_deprecated: {
10+
code: 'tag-option-deprecated',
11+
message: "'tag' option is deprecated — use 'customElement' instead"
1212
},
1313
unused_export_let: (component: string, property: string) => ({
1414
code: 'unused-export-let',
@@ -32,7 +32,7 @@ export default {
3232
}),
3333
missing_custom_element_compile_options: {
3434
code: 'missing-custom-element-compile-options',
35-
message: "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?"
35+
message: "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?"
3636
},
3737
css_unused_selector: (selector: string) => ({
3838
code: 'css-unused-selector',

0 commit comments

Comments
 (0)