Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom elements rework #8457

Merged
merged 42 commits into from
May 2, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
52f911c
wip
dummdidumm Apr 5, 2023
1131584
handle boolean attributes
dummdidumm Apr 5, 2023
82de3f6
introduce svelte:option cePropsDefinition
dummdidumm Apr 6, 2023
a4102f0
lint, cleanup, fix test
dummdidumm Apr 6, 2023
00d0405
handle dynamic slot content
dummdidumm Apr 6, 2023
07af512
inline styles for custom elements mode
dummdidumm Apr 6, 2023
62e6d06
remove unused styles param
dummdidumm Apr 6, 2023
868fb23
mount and render after a tick
dummdidumm Apr 11, 2023
a31d4a5
use $set, remove lowercase property handling in favor of attribute co…
dummdidumm Apr 11, 2023
931b7f5
cePropsDefinition -> ceProps
dummdidumm Apr 11, 2023
1241f43
test for context
dummdidumm Apr 11, 2023
765023d
fix: html space entities lost in component slot (#8464)
xxkl1 Apr 11, 2023
75aec41
breaking: send in/out to transition fn (#8318)
tivac Apr 11, 2023
c729829
chore: remove node<14 tests (#8482)
dummdidumm Apr 11, 2023
eedacc9
chore: simplify Svelte 4 CI (#8487)
benmccann Apr 12, 2023
abd760d
chore: bump engines field (#8489)
benmccann Apr 12, 2023
1e2cfa4
chore: upgrade to TypeScript 5 (#8488)
benmccann Apr 12, 2023
42e0f7d
chore: Svelte 4 dependency upgrades (#8486)
benmccann Apr 12, 2023
c9ccd6e
chore: upgrade rollup (#8491)
benmccann Apr 12, 2023
149c100
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 12, 2023
96e9768
handle event listener registration before mount; handle unregister
dummdidumm Apr 12, 2023
840a7be
implement shadowdom option
dummdidumm Apr 12, 2023
573784c
chore: run fewer CI jobs (#8496)
benmccann Apr 13, 2023
d6bcddd
breaking: improve types for `createEventDispatcher` (#7224)
ivanhofer Apr 14, 2023
56a6738
breaking: conditional ActionReturn type if Parameter is void (#7442)
tanhauhau Apr 14, 2023
9460616
feat: add `a11y-no-static-element-interactions` compiler rule (#8251)
timmcca-be Apr 14, 2023
88728e3
fix: bind null option and input values consistently (#8328)
theodorejb Apr 14, 2023
d1a9722
feat: add a11y `no-noninteractive-element-interactions` (#8391)
ngtr6788 Apr 14, 2023
e790740
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 14, 2023
daadae9
changelog
dummdidumm Apr 14, 2023
39333b1
chore: remove Node 8 and 10 logic (#8503)
baseballyama Apr 15, 2023
662804e
chore: produce single bundle for runtime with multiple entrypoints (#…
gtm-nayan Apr 18, 2023
350c6c3
breaking: update onMount type definition to prevent async function re…
chrskerr Apr 18, 2023
1f93e30
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 18, 2023
9eb73e0
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 18, 2023
b63a6aa
Merge branch 'custom-elements-rework' of https://github.com/dummdidum…
dummdidumm Apr 18, 2023
00e6df8
add custom element version as static property for later custom regist…
dummdidumm Apr 19, 2023
1e82718
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 28, 2023
5804b69
ceProps -> customElement.props, tag -> customElement/customElement.ta…
dummdidumm May 2, 2023
ad00392
lint, tests
dummdidumm May 2, 2023
424fcbc
hide from public type definition
dummdidumm May 2, 2023
8fe3c3a
fix rollup config
dummdidumm May 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ module.exports = {
'estree'
],
'svelte3/compiler': require('./compiler')
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off'
}
};
6 changes: 5 additions & 1 deletion elements/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1589,7 +1589,11 @@ export interface SvelteHTMLElements {
'svelte:document': HTMLAttributes<Document>;
'svelte:body': HTMLAttributes<HTMLElement>;
'svelte:fragment': { slot?: string };
'svelte:options': { [name: string]: any };
'svelte:options': {
tag?: string | null | undefined;
cePropsDefinition?: Record<string, { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }> | undefined,
[name: string]: any
};
'svelte:head': { [name: string]: any };

[name: string]: { [name: string]: any };
Expand Down
46 changes: 44 additions & 2 deletions src/compiler/compile/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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';
Expand All @@ -45,6 +45,7 @@ interface ComponentOptions {
immutable?: boolean;
accessors?: boolean;
preserveWhitespace?: boolean;
cePropsDefinition?: Record<string, { reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', attribute?: string }>;
}

const regex_leading_directory_separator = /^[/\\]/;
Expand Down Expand Up @@ -192,7 +193,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);
}

Expand Down Expand Up @@ -544,6 +545,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));
}
Expand Down Expand Up @@ -1570,6 +1574,44 @@ function process_component_options(component: Component, nodes) {
break;
}

case 'cePropsDefinition': {
const error = () => component.error(attribute, compiler_errors.invalid_cePropsDefinition_attribute);
const { value } = attribute;
const chunk = value[0];
component_options.cePropsDefinition = {};

if (!chunk) {
break;
}

if (value.length > 1 || chunk.expression?.type !== 'ObjectExpression') {
return error();
}

const object = chunk.expression as ObjectExpression;
for (const property of object.properties) {
if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression') {
return error();
}
component_options.cePropsDefinition[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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe give specific explanation of why it failed, eg

'cePropsDefinition' must be a statically analyzable object literal of the form '{ prop: { attribute?: string; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', reflect?: boolean; } }', found key 'xxx'
'cePropsDefinition' must be a statically analyzable object literal of the form '{ prop: { attribute?: string; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', reflect?: boolean; } }', the value of reflect is not boolean

}
component_options.cePropsDefinition[property.key.name][prop.key.name] = prop.value.value;
}
}

break;
}

case 'namespace': {
const ns = get_value(attribute, compiler_errors.invalid_namespace_attribute);

Expand Down
5 changes: 5 additions & 0 deletions src/compiler/compile/compiler_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ export default {
code: 'invalid-tag-attribute',
message: "'tag' must be a string literal"
},
invalid_cePropsDefinition_attribute: {
code: 'invalid-cePropsDefinition-attribute',
message: "'cePropsDefinition' must be a statically analyzable object literal of the form " +
"'{ prop: { attribute?: string; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', reflect?: boolean; } }'"
},
invalid_namespace_property: (namespace: string, suggestion?: string) => ({
code: 'invalid-namespace-property',
message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '')
Expand Down
12 changes: 5 additions & 7 deletions src/compiler/compile/css/Stylesheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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 => {
Expand Down
127 changes: 41 additions & 86 deletions src/compiler/compile/render_dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ 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 { RawSourceMap, DecodedSourceMap } from '@ampproject/remapping/dist/types/types';
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,
Expand All @@ -25,17 +24,14 @@ 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) {
const file = component.file ? x`"${component.file}"` : x`undefined`;
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');

Expand All @@ -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) {
Expand Down Expand Up @@ -519,91 +514,51 @@ 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();
const superclass = {
type: 'Identifier',
name: options.dev ? '@SvelteComponentDev' : '@SvelteComponent'
};

${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)`}

@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'
};

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);
}
push_array(declaration.body.body, accessors);
body.push(declaration);

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 && component.tag != null) {
const props_str = writable_props.reduce((def, prop) => {
def[prop.export_name] = component.component_options.cePropsDefinition?.[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(',');

body.push(
b`@_customElements.define("${component.tag}", @create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}]));`
);
}

return { js: flatten(body), css };
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/compile/render_dom/wrappers/Slot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export default class SlotWrapper extends Wrapper {
const ${slot_definition} = ${renderer.reference('#slots')}.${slot_name};
const ${slot} = @create_slot(${slot_definition}, #ctx, ${renderer.reference('$$scope')}, ${get_slot_context_fn});
${has_fallback ? b`const ${slot_or_fallback} = ${slot} || ${this.fallback.name}(#ctx);` : null}
${has_fallback && this.renderer.options.customElement && this.renderer.component.component_options.tag
// This ensures that fallback content is rendered into the <slot> element given by the custom element wrapper
? b`if (${slot_or_fallback}.$$c_e) { ${slot_or_fallback}.$$c_e = ${this.fallback.name}(#ctx); }`
: null}
`);

block.chunks.create.push(
Expand Down
2 changes: 1 addition & 1 deletion src/compiler/compile/render_ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/compiler/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/compiler/parse/state/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading