From 52f911cae4fdbd4fb4b2d52ded05c012ff90b89b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 5 Apr 2023 18:25:46 +0200 Subject: [PATCH 01/36] wip --- src/compiler/compile/Component.ts | 2 +- src/compiler/compile/css/Stylesheet.ts | 12 +- src/compiler/compile/render_dom/index.ts | 97 +++---- .../compile/render_dom/wrappers/Slot.ts | 4 + src/compiler/compile/render_ssr/index.ts | 2 +- src/compiler/parse/state/tag.ts | 2 +- src/runtime/internal/Component.ts | 252 ++++++++++++++---- .../samples/action/main.svelte | 20 ++ test/custom-elements/samples/action/test.js | 14 + .../samples/escaped-css/test.js | 7 +- .../samples/events/main.svelte | 9 + test/custom-elements/samples/events/test.js | 18 ++ .../samples/extended-builtin/test.js | 7 +- .../samples/html-slots/test.js | 2 +- .../samples/nested.skip/Counter.svelte | 7 - .../samples/nested.skip/main.svelte | 10 - .../samples/nested.skip/test.js | 17 -- .../samples/nested/Counter.svelte | 14 + .../samples/nested/main.svelte | 13 + test/custom-elements/samples/nested/test.js | 20 ++ .../samples/new-styled/test.js | 7 +- test/custom-elements/samples/new/main.svelte | 7 - test/custom-elements/samples/new/test.js | 18 -- .../samples/no-svelte-options/test.js | 3 +- .../samples/no-tag-warning/test.js | 3 +- test/custom-elements/samples/no-tag/test.js | 3 +- .../samples/oncreate/main.svelte | 18 +- test/custom-elements/samples/oncreate/test.js | 5 +- .../custom-elements/samples/ondestroy/test.js | 7 +- test/custom-elements/samples/props/test.js | 6 +- .../samples/reflect-attributes/main.svelte | 20 ++ .../reflect-attributes/my-widget.svelte | 18 ++ .../samples/reflect-attributes/test.js | 19 ++ test/tsconfig.json | 1 + 34 files changed, 447 insertions(+), 217 deletions(-) create mode 100644 test/custom-elements/samples/action/main.svelte create mode 100644 test/custom-elements/samples/action/test.js create mode 100644 test/custom-elements/samples/events/main.svelte create mode 100644 test/custom-elements/samples/events/test.js delete mode 100644 test/custom-elements/samples/nested.skip/Counter.svelte delete mode 100644 test/custom-elements/samples/nested.skip/main.svelte delete mode 100644 test/custom-elements/samples/nested.skip/test.js create mode 100644 test/custom-elements/samples/nested/Counter.svelte create mode 100644 test/custom-elements/samples/nested/main.svelte create mode 100644 test/custom-elements/samples/nested/test.js delete mode 100644 test/custom-elements/samples/new/main.svelte delete mode 100644 test/custom-elements/samples/new/test.js create mode 100644 test/custom-elements/samples/reflect-attributes/main.svelte create mode 100644 test/custom-elements/samples/reflect-attributes/my-widget.svelte create mode 100644 test/custom-elements/samples/reflect-attributes/test.js diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index e87cf6218af2..b2c2fff248a2 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -192,7 +192,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); } 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 58b7a8317b29..058c132281db 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -6,7 +6,7 @@ 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'; @@ -25,9 +25,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 +32,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,7 +49,6 @@ export default function dom( const add_css = component.get_unique_name('add_css'); const should_add_css = ( - !options.customElement && !!styles && options.css === 'injected' ); @@ -519,8 +515,35 @@ export default function dom( } } - if (options.customElement) { + 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); + } + + 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; + + push_array(declaration.body.body, accessors); + body.push(declaration); + if (options.customElement && component.tag != null) { let init_props = x`@attribute_to_object(this.attributes)`; if (uses_slots) { init_props = x`{ ...${init_props}, $$slots: @get_custom_elements_slots(this) }`; @@ -553,57 +576,15 @@ export default function dom( } `[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 - }); - } - - 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); - } - - 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; - - push_array(declaration.body.body, accessors); - - body.push(declaration); + const props_str = writable_props.map(prop => `"${prop.export_name}"`).join(','); + 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}, [${props_str}], [${slots_str}], [${accessors_str}]));` + ); } return { js: flatten(body), css }; diff --git a/src/compiler/compile/render_dom/wrappers/Slot.ts b/src/compiler/compile/render_dom/wrappers/Slot.ts index 0a589e339489..380d7527b3d7 100644 --- a/src/compiler/compile/render_dom/wrappers/Slot.ts +++ b/src/compiler/compile/render_dom/wrappers/Slot.ts @@ -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.options.tag + // This ensures that fallback content is rendered into the element given by the custom element wrapper + ? b`if (${slot_or_fallback}.$$c_e) ${this.fallback.name}(#ctx);` + : null} `); block.chunks.create.push( 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/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..2a1655a89685 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 { add_render_callback, flush, flush_render_callbacks, schedule_update, dirty_components, tick } 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, set_custom_element_data, 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,202 @@ 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; + + constructor( + private $$componentCtor: ComponentType, + private $$slots: string[], + ) { super(); this.attachShadow({ mode: 'open' }); } - connectedCallback() { - const { on_mount } = this.$$; - this.$$.on_disconnect = on_mount.map(run).filter(is_function); + 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.$$component!.$on(type, listener); + super.addEventListener(type, listener, options); + } - // @ts-ignore todo: improve typings - for (const key in this.$$.slotted) { - // @ts-ignore todo: improve typings - this.appendChild(this.$$.slotted[key]); + connectedCallback() { + this.$$connected = true; + if (!this.$$component) { + for (const attribute of this.attributes) { + // this.$$data takes precedence over this.attributes + if (!(attribute.name in this.$$data)) { + this.$$data[attribute.name] = attribute.value; + } + } + + function create_slot(name: string) { + return () => { + let node: HTMLSlotElement; + return { + 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) + } + }, + $$c_e: true + }; + }; + } + + let $$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)]; + } + } + + // Dilemma: We need to set the component props eagerly or they have the wrong value for actions/onMount etc. + // Boolean attributes are represented by the empty string, and we don't know if they represent boolean or string props. + this.$$component = new this.$$componentCtor({ + target: this.shadowRoot!, + props: { + $$slots, + $$scope: { + ctx: [] + } + } + }); + // ensures that works correctly + Object.keys(this.$$data).forEach(key => { + set_custom_element_data(this, key, this.$$data[key]); + this.$$data[key] = this[key]; // "" -> true for boolean attributes + }); } } - attributeChangedCallback(attr, _oldValue, newValue) { - this[attr] = newValue; + // TODO 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 probably helpful + attributeChangedCallback(attr: string, _oldValue: any, newValue: any) { + if (this.$$reflecting) return; + + set_custom_element_data(this.$$data, attr, newValue); + this.$$component![attr] = this.$$data; } disconnectedCallback() { - run_all(this.$$.on_disconnect); + this.$$connected = false; + // In a microtask, because this could be a move within the DOM + tick().then(() => { + if (!this.$$connected) { + this.$$component!.$destroy(); + this.$$component = undefined; + } + }); } + }; +} - $destroy() { - destroy_component(this, 1); - this.$destroy = noop; - } +/** + * Attribute value types that should be reflected to the DOM. Helpful + * for people relying on the custom element's attributes to be present, + * for example when using a CSS selector which relies on an attribute. + */ +const should_reflect = ['string', 'number', 'boolean']; - $on(type, callback) { - // TODO should this delegate to addEventListener? - if (!is_function(callback)) { - return noop; +function camelToHyphen(str: string) { + return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +} + +/** + * Turn a Svelte component into a custom element. + * @param Component A Svelte component constructor + * @param props The props to observe + * @param slots The slots to create + * @param accessors Other accessors besides the ones for props the component has + * @param styles Additional styles to apply to the shadow root (not needed for Svelte components compiled with `customElement: true`) + * @returns A custom element class + */ +export function create_custom_element( + Component: ComponentType, + props: string[], + slots: string[], + accessors: string[], + styles?: string, +) { + const Class = class extends SvelteElement { + constructor() { + super(Component, slots); + if (styles) { + const style = document.createElement('style'); + style.textContent = styles; + this.shadowRoot!.appendChild(style); } - const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = [])); - callbacks.push(callback); + } - return () => { - const index = callbacks.indexOf(callback); - if (index !== -1) callbacks.splice(index, 1); - }; + static get observedAttributes() { + return props; } + }; - $set($$props) { - if (this.$$set && !is_empty($$props)) { - this.$$.skip_bound = true; - this.$$set($$props); - this.$$.skip_bound = false; + function createProperty(name: string, prop: string) { + Object.defineProperty(Class.prototype, name, { + get() { + return this.$$component && prop in this.$$component + ? this.$$component[prop] + : this.$$data[prop]; + }, + + set(value) { + this.$$data[prop] = value; + + if (this.$$component) { + if(should_reflect.indexOf(typeof value) !== -1) { + this.$$reflecting = true; + if (value === false || value == null) { + this.removeAttribute(prop); + } else { + this.setAttribute(prop, value); + } + this.$$reflecting = false; + } + + this.$$component[prop] = value; + } } + }) + } + + props.forEach((prop) => { + createProperty(prop, prop); + // will be ce.camcelcase = "foo" + const lower = prop.toLowerCase(); + if (lower !== prop) { + createProperty(lower, prop); } - }; + // also support hyphenated version where will be ce['camel-case'] = "foo" + const hyphen = camelToHyphen(prop); + if (hyphen !== lower) { + createProperty(hyphen, prop) + } + }); + + accessors.forEach(accessor => { + Object.defineProperty(Class.prototype, accessor, { + get() { + return this.$$component?.[accessor]; + }, + }) + }); + + return Class; } /** diff --git a/test/custom-elements/samples/action/main.svelte b/test/custom-elements/samples/action/main.svelte new file mode 100644 index 000000000000..1f8f5fe7e67d --- /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..6bf1d3850a62 --- /dev/null +++ b/test/custom-elements/samples/action/test.js @@ -0,0 +1,14 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + target.innerHTML = ''; + const el = target.querySelector('custom-element'); + assert.deepEqual(el.events, ['foo']); + + el.name = 'bar'; + assert.deepEqual(el.events, ['foo', 'bar']); + + target.innerHTML = ''; + assert.deepEqual(el.events, ['foo', 'bar', 'destroy']); +} diff --git a/test/custom-elements/samples/escaped-css/test.js b/test/custom-elements/samples/escaped-css/test.js index 6277ccba32a3..e7df08da1cd9 100644 --- a/test/custom-elements/samples/escaped-css/test.js +++ b/test/custom-elements/samples/escaped-css/test.js @@ -1,11 +1,8 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; +import './main.svelte'; export default function (target) { - new CustomElement({ - target - }); - + target.innerHTML = ''; const icon = target.querySelector('custom-element').shadowRoot.querySelector('.icon'); const before = getComputedStyle(icon, '::before'); diff --git a/test/custom-elements/samples/events/main.svelte b/test/custom-elements/samples/events/main.svelte new file mode 100644 index 000000000000..1c8355b01e19 --- /dev/null +++ b/test/custom-elements/samples/events/main.svelte @@ -0,0 +1,9 @@ + + + + + diff --git a/test/custom-elements/samples/events/test.js b/test/custom-elements/samples/events/test.js new file mode 100644 index 000000000000..2c8b7832d0d6 --- /dev/null +++ b/test/custom-elements/samples/events/test.js @@ -0,0 +1,18 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + target.innerHTML = ''; + const el = target.querySelector('custom-element'); + + const events = []; + el.addEventListener('custom', e => { + events.push(e.detail); + }); + el.addEventListener('click', () => { + events.push('click'); + }); + + el.shadowRoot.querySelector('button').click(); + assert.deepEqual(events, ['foo', 'click']); +} diff --git a/test/custom-elements/samples/extended-builtin/test.js b/test/custom-elements/samples/extended-builtin/test.js index a2f253e5d477..1eac8d852eb0 100644 --- a/test/custom-elements/samples/extended-builtin/test.js +++ b/test/custom-elements/samples/extended-builtin/test.js @@ -1,11 +1,8 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; +import './main.svelte'; export default function (target) { - new CustomElement({ - target - }); - + target.innerHTML = ''; assert.equal(target.innerHTML, ''); const el = target.querySelector('custom-element'); diff --git a/test/custom-elements/samples/html-slots/test.js b/test/custom-elements/samples/html-slots/test.js index 06d18d9944d1..c82a2d24ad90 100644 --- a/test/custom-elements/samples/html-slots/test.js +++ b/test/custom-elements/samples/html-slots/test.js @@ -13,5 +13,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/nested.skip/Counter.svelte b/test/custom-elements/samples/nested.skip/Counter.svelte deleted file mode 100644 index 87cde48466ce..000000000000 --- a/test/custom-elements/samples/nested.skip/Counter.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - - - 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..45003ac72457 --- /dev/null +++ b/test/custom-elements/samples/nested/Counter.svelte @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/test/custom-elements/samples/nested/main.svelte b/test/custom-elements/samples/nested/main.svelte new file mode 100644 index 000000000000..3170fe22fa50 --- /dev/null +++ b/test/custom-elements/samples/nested/main.svelte @@ -0,0 +1,13 @@ + + + + + + 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..29029f3d2c58 --- /dev/null +++ b/test/custom-elements/samples/nested/test.js @@ -0,0 +1,20 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default async function (target) { + target.innerHTML = ''; + const el = target.querySelector('my-app'); + const button = el.shadowRoot.querySelector('button'); + const span = el.shadowRoot.querySelector('span'); + + assert.equal(el.counter.count, 0); + assert.equal(button.innerHTML, 'count: 0'); + assert.equal(span.innerHTML, 'slot 0'); + 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/test.js b/test/custom-elements/samples/new-styled/test.js index 72c2cecd10cc..343345190007 100644 --- a/test/custom-elements/samples/new-styled/test.js +++ b/test/custom-elements/samples/new-styled/test.js @@ -1,12 +1,9 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; +import './main.svelte'; export default function (target) { target.innerHTML = '

unstyled

'; - - new CustomElement({ - target - }); + target.appendChild(document.createElement('custom-element')); 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-svelte-options/test.js b/test/custom-elements/samples/no-svelte-options/test.js index e6ce82d1a4ee..a3c2a4857479 100644 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ b/test/custom-elements/samples/no-svelte-options/test.js @@ -1,8 +1,9 @@ import * as assert from 'assert'; import CustomElement from './main.svelte'; +import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', CustomElement); + customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js index e6ce82d1a4ee..a3c2a4857479 100644 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ b/test/custom-elements/samples/no-tag-warning/test.js @@ -1,8 +1,9 @@ import * as assert from 'assert'; import CustomElement from './main.svelte'; +import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', CustomElement); + customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/no-tag/test.js b/test/custom-elements/samples/no-tag/test.js index e6ce82d1a4ee..a3c2a4857479 100644 --- a/test/custom-elements/samples/no-tag/test.js +++ b/test/custom-elements/samples/no-tag/test.js @@ -1,8 +1,9 @@ import * as assert from 'assert'; import CustomElement from './main.svelte'; +import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', CustomElement); + customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/oncreate/main.svelte b/test/custom-elements/samples/oncreate/main.svelte index 23819e660f15..e22d101eca3b 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..cd27d9de86c4 100644 --- a/test/custom-elements/samples/oncreate/test.js +++ b/test/custom-elements/samples/oncreate/test.js @@ -1,10 +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 = ''; const el = target.querySelector('my-app'); + await tick(); + assert.ok(el.wasCreated); assert.ok(el.propsInitialized); } diff --git a/test/custom-elements/samples/ondestroy/test.js b/test/custom-elements/samples/ondestroy/test.js index 61375bfa966a..3092613fc3e3 100644 --- a/test/custom-elements/samples/ondestroy/test.js +++ b/test/custom-elements/samples/ondestroy/test.js @@ -1,11 +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 = ''; 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/test.js b/test/custom-elements/samples/props/test.js index 41ca77d29d02..3d93c60b83f2 100644 --- a/test/custom-elements/samples/props/test.js +++ b/test/custom-elements/samples/props/test.js @@ -1,10 +1,8 @@ import * as assert from 'assert'; -import CustomElement from './main.svelte'; +import './main.svelte'; export default function (target) { - new CustomElement({ - target - }); + target.innerHTML = ''; assert.equal(target.innerHTML, ''); 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..4aadb8bc06fa --- /dev/null +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -0,0 +1,20 @@ + + + + +
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..ef6d071d2c3e --- /dev/null +++ b/test/custom-elements/samples/reflect-attributes/my-widget.svelte @@ -0,0 +1,18 @@ + + + + +
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..4b392868c6f3 --- /dev/null +++ b/test/custom-elements/samples/reflect-attributes/test.js @@ -0,0 +1,19 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + target.innerHTML = ''; + 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/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, From 1131584adda4cdfb40ae8ddfb4be9b2b74576d3c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 5 Apr 2023 20:53:29 +0200 Subject: [PATCH 02/36] handle boolean attributes --- src/compiler/compile/Component.ts | 3 + src/compiler/compile/render_dom/index.ts | 4 +- src/compiler/interfaces.ts | 5 +- src/runtime/internal/Component.ts | 63 ++++++++++--------- test/custom-elements/samples/action/test.js | 11 ++-- .../samples/reflect-attributes/main.svelte | 2 +- .../reflect-attributes/my-widget.svelte | 2 +- 7 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index b2c2fff248a2..c90272620ced 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -544,6 +544,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)); } diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index 058c132281db..200dbdb2ba28 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -576,14 +576,14 @@ export default function dom( } `[0] as ClassDeclaration; - const props_str = writable_props.map(prop => `"${prop.export_name}"`).join(','); + const props_str = JSON.stringify(writable_props.map(prop => prop.is_boolean ? { name: prop.export_name, type: 'boolean' } : prop.export_name)); 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}, [${props_str}], [${slots_str}], [${accessors_str}]));` + b`@_customElements.define("${component.tag}", @create_custom_element(${name}, ${props_str}, [${slots_str}], [${accessors_str}]));` ); } 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/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index 2a1655a89685..b370bfbff016 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -1,7 +1,7 @@ import { add_render_callback, flush, flush_render_callbacks, schedule_update, dirty_components, tick } 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, set_custom_element_data, get_custom_elements_slots, insert } 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'; @@ -151,6 +151,7 @@ if (typeof HTMLElement === 'function') { private $$connected = false; private $$data = {}; private $$reflecting = false; + private $$boolean_props: string[] = []; constructor( private $$componentCtor: ComponentType, @@ -171,13 +172,6 @@ if (typeof HTMLElement === 'function') { connectedCallback() { this.$$connected = true; if (!this.$$component) { - for (const attribute of this.attributes) { - // this.$$data takes precedence over this.attributes - if (!(attribute.name in this.$$data)) { - this.$$data[attribute.name] = attribute.value; - } - } - function create_slot(name: string) { return () => { let node: HTMLSlotElement; @@ -209,22 +203,25 @@ if (typeof HTMLElement === 'function') { } } + for (const attribute of this.attributes) { + // this.$$data takes precedence over this.attributes + if (!(attribute.name in this.$$data)) { + this.$$data[attribute.name] = get_custom_element_value(attribute.name, attribute.value, this.$$boolean_props); + } + } + // Dilemma: We need to set the component props eagerly or they have the wrong value for actions/onMount etc. // Boolean attributes are represented by the empty string, and we don't know if they represent boolean or string props. this.$$component = new this.$$componentCtor({ target: this.shadowRoot!, props: { + ...this.$$data, $$slots, $$scope: { ctx: [] } } }); - // ensures that works correctly - Object.keys(this.$$data).forEach(key => { - set_custom_element_data(this, key, this.$$data[key]); - this.$$data[key] = this[key]; // "" -> true for boolean attributes - }); } } @@ -233,8 +230,8 @@ if (typeof HTMLElement === 'function') { attributeChangedCallback(attr: string, _oldValue: any, newValue: any) { if (this.$$reflecting) return; - set_custom_element_data(this.$$data, attr, newValue); - this.$$component![attr] = this.$$data; + this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$boolean_props); + this.$$component![attr] = this.$$data[attr]; } disconnectedCallback() { @@ -261,6 +258,10 @@ function camelToHyphen(str: string) { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } +function get_custom_element_value(prop, value, boolean_attrs) { + return value === '' && boolean_attrs.indexOf(prop) !== -1 ? true : value; +} + /** * Turn a Svelte component into a custom element. * @param Component A Svelte component constructor @@ -272,14 +273,18 @@ function camelToHyphen(str: string) { */ export function create_custom_element( Component: ComponentType, - props: string[], + props: (string | { name: string; type: 'boolean' })[], slots: string[], accessors: string[], styles?: string, ) { + const prop_names = props.map((prop) => (typeof prop === 'string' ? prop : prop.name)); + const boolean_props = props.filter((prop) => typeof prop !== 'string').map((prop) => (prop as { name:string }).name); + const Class = class extends SvelteElement { constructor() { super(Component, slots); + this.$$boolean_props = boolean_props; if (styles) { const style = document.createElement('style'); style.textContent = styles; @@ -288,7 +293,7 @@ export function create_custom_element( } static get observedAttributes() { - return props; + return prop_names; } }; @@ -301,26 +306,26 @@ export function create_custom_element( }, set(value) { - this.$$data[prop] = value; + this.$$data[prop] = get_custom_element_value(prop, value, boolean_props); if (this.$$component) { - if(should_reflect.indexOf(typeof value) !== -1) { - this.$$reflecting = true; - if (value === false || value == null) { - this.removeAttribute(prop); - } else { - this.setAttribute(prop, value); - } - this.$$reflecting = false; - } - this.$$component[prop] = value; } + + if(should_reflect.indexOf(typeof value) !== -1 || value == null) { + this.$$reflecting = true; + if (value === false || value == null) { + this.removeAttribute(prop); + } else { + this.setAttribute(prop, value); + } + this.$$reflecting = false; + } } }) } - props.forEach((prop) => { + prop_names.forEach((prop) => { createProperty(prop, prop); // will be ce.camcelcase = "foo" const lower = prop.toLowerCase(); diff --git a/test/custom-elements/samples/action/test.js b/test/custom-elements/samples/action/test.js index 6bf1d3850a62..e3fa0a808f83 100644 --- a/test/custom-elements/samples/action/test.js +++ b/test/custom-elements/samples/action/test.js @@ -1,14 +1,17 @@ +import { tick } from 'svelte'; import * as assert from 'assert'; import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = ''; const el = target.querySelector('custom-element'); - assert.deepEqual(el.events, ['foo']); + const events = el.events; // need to get the array reference, else it's gone when destroyed + assert.deepEqual(events, ['foo']); el.name = 'bar'; - assert.deepEqual(el.events, ['foo', 'bar']); + assert.deepEqual(events, ['foo', 'bar']); target.innerHTML = ''; - assert.deepEqual(el.events, ['foo', 'bar', 'destroy']); + await tick(); + assert.deepEqual(events, ['foo', 'bar', 'destroy']); } diff --git a/test/custom-elements/samples/reflect-attributes/main.svelte b/test/custom-elements/samples/reflect-attributes/main.svelte index 4aadb8bc06fa..726b0838c9b6 100644 --- a/test/custom-elements/samples/reflect-attributes/main.svelte +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -2,7 +2,7 @@ diff --git a/test/custom-elements/samples/reflect-attributes/my-widget.svelte b/test/custom-elements/samples/reflect-attributes/my-widget.svelte index ef6d071d2c3e..02685c8cb3c3 100644 --- a/test/custom-elements/samples/reflect-attributes/my-widget.svelte +++ b/test/custom-elements/samples/reflect-attributes/my-widget.svelte @@ -1,7 +1,7 @@ From 82de3f625dc3acfd47d5de8dcce0c1068ceb4d5e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 6 Apr 2023 12:12:59 +0200 Subject: [PATCH 03/36] introduce svelte:option cePropsDefinition --- elements/index.d.ts | 6 +- src/compiler/compile/Component.ts | 43 ++++++++- src/compiler/compile/compiler_errors.ts | 5 + src/compiler/compile/render_dom/index.ts | 36 ++----- src/runtime/internal/Component.ts | 94 ++++++++++++------- .../samples/camel-case-attribute/main.svelte | 17 ++++ .../samples/camel-case-attribute/test.js | 24 +++++ .../samples/ce-options-valid/main.svelte | 12 +++ .../samples/ce-options-valid/test.js | 11 +++ .../samples/no-svelte-options/test.js | 2 +- .../samples/no-tag-warning/test.js | 2 +- test/custom-elements/samples/no-tag/test.js | 2 +- .../samples/reflect-attributes/main.svelte | 7 +- .../reflect-attributes/my-widget.svelte | 5 +- 14 files changed, 192 insertions(+), 74 deletions(-) create mode 100644 test/custom-elements/samples/camel-case-attribute/main.svelte create mode 100644 test/custom-elements/samples/camel-case-attribute/test.js create mode 100644 test/custom-elements/samples/ce-options-valid/main.svelte create mode 100644 test/custom-elements/samples/ce-options-valid/test.js diff --git a/elements/index.d.ts b/elements/index.d.ts index 7595d767bf11..fcdeacecf8b5 100644 --- a/elements/index.d.ts +++ b/elements/index.d.ts @@ -1589,7 +1589,11 @@ export interface SvelteHTMLElements { 'svelte:document': HTMLAttributes; 'svelte:body': HTMLAttributes; 'svelte:fragment': { slot?: string }; - 'svelte:options': { [name: string]: any }; + 'svelte:options': { + tag?: string | null | undefined; + cePropsDefinition?: Record | undefined, + [name: string]: any + }; 'svelte:head': { [name: string]: any }; [name: string]: { [name: string]: any }; diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index c90272620ced..9247436e64ad 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -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'; @@ -45,6 +45,7 @@ interface ComponentOptions { immutable?: boolean; accessors?: boolean; preserveWhitespace?: boolean; + cePropsDefinition?: Record; } const regex_leading_directory_separator = /^[/\\]/; @@ -1524,7 +1525,7 @@ function process_component_options(component: Component, nodes) { ? component.compile_options.accessors : !!component.compile_options.customElement, preserveWhitespace: !!component.compile_options.preserveWhitespace, - namespace: component.compile_options.namespace + namespace: component.compile_options.namespace, }; const node = nodes.find(node => node.name === 'svelte:options'); @@ -1573,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(); + } + 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); diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index bad3911673fc..fb9130f6406c 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -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}'?)` : '') diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index 200dbdb2ba28..b05feed749ff 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -12,7 +12,6 @@ import { RawSourceMap, DecodedSourceMap } from '@ampproject/remapping/dist/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, @@ -549,41 +548,20 @@ export default function dom( 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)`} - - @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); - } - - ${(props.length > 0 || uses_props || uses_rest) && b` - if (options.props) { - this.$set(options.props); - @flush(); - }`} - } - } + 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; - - const props_str = JSON.stringify(writable_props.map(prop => prop.is_boolean ? { name: prop.export_name, type: 'boolean' } : prop.export_name)); + 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}, ${props_str}, [${slots_str}], [${accessors_str}]));` + b`@_customElements.define("${component.tag}", @create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}]));` ); } diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index b370bfbff016..c2225f626747 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -151,7 +151,7 @@ if (typeof HTMLElement === 'function') { private $$connected = false; private $$data = {}; private $$reflecting = false; - private $$boolean_props: string[] = []; + private $$props_definition: Record = {}; constructor( private $$componentCtor: ComponentType, @@ -205,13 +205,12 @@ if (typeof HTMLElement === 'function') { for (const attribute of this.attributes) { // this.$$data takes precedence over this.attributes - if (!(attribute.name in this.$$data)) { - this.$$data[attribute.name] = get_custom_element_value(attribute.name, attribute.value, this.$$boolean_props); + 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'); } } - // Dilemma: We need to set the component props eagerly or they have the wrong value for actions/onMount etc. - // Boolean attributes are represented by the empty string, and we don't know if they represent boolean or string props. this.$$component = new this.$$componentCtor({ target: this.shadowRoot!, props: { @@ -225,12 +224,13 @@ if (typeof HTMLElement === 'function') { } } - // TODO 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 probably helpful + // 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; - this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$boolean_props); + attr = this.$$get_prop_name(attr); + this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$props_definition, 'toProp'); this.$$component![attr] = this.$$data[attr]; } @@ -244,28 +244,54 @@ if (typeof HTMLElement === 'function') { } }); } + + private $$get_prop_name(attribute_name: string): string { + return Object.keys(this.$$props_definition).find(key => this.$$props_definition[key].attribute === attribute_name) || attribute_name; + } }; } -/** - * Attribute value types that should be reflected to the DOM. Helpful - * for people relying on the custom element's attributes to be present, - * for example when using a CSS selector which relies on an attribute. - */ -const should_reflect = ['string', 'number', 'boolean']; - -function camelToHyphen(str: string) { - return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +function get_custom_element_value(prop, value, props_definition: Record, transform?: 'toAttribute' | 'toProp') { + value = props_definition[prop]?.type === 'Boolean' && typeof value !== 'boolean' ? value != null : value; + if (!transform || !props_definition[prop]) { + return value; + } else if (transform === 'toAttribute') { + switch (props_definition[prop].type) { + case 'Object': + case 'Array': + return JSON.stringify(value); + case 'Boolean': + return value ? '' : null; + case 'Number': + return value == null ? null : value; + default: + return value; + } + } else { + switch (props_definition[prop].type) { + case 'Object': + case 'Array': + return JSON.parse(value); + case 'Boolean': + return value !== null; + case 'Number': + return value == null ? null : +value; + default: + return value; + } + } } -function get_custom_element_value(prop, value, boolean_attrs) { - return value === '' && boolean_attrs.indexOf(prop) !== -1 ? true : value; +interface CustomElementPropDefinition { + reflect?: boolean; + type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object'; + attribute?: string; } /** * Turn a Svelte component into a custom element. * @param Component A Svelte component constructor - * @param props The props to observe + * @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 styles Additional styles to apply to the shadow root (not needed for Svelte components compiled with `customElement: true`) @@ -273,18 +299,15 @@ function get_custom_element_value(prop, value, boolean_attrs) { */ export function create_custom_element( Component: ComponentType, - props: (string | { name: string; type: 'boolean' })[], + props_definition: Record, slots: string[], accessors: string[], styles?: string, ) { - const prop_names = props.map((prop) => (typeof prop === 'string' ? prop : prop.name)); - const boolean_props = props.filter((prop) => typeof prop !== 'string').map((prop) => (prop as { name:string }).name); - const Class = class extends SvelteElement { constructor() { super(Component, slots); - this.$$boolean_props = boolean_props; + this.$$props_definition = props_definition; if (styles) { const style = document.createElement('style'); style.textContent = styles; @@ -293,7 +316,7 @@ export function create_custom_element( } static get observedAttributes() { - return prop_names; + return Object.keys(props_definition).map(key => props_definition[key].attribute || key); } }; @@ -306,37 +329,36 @@ export function create_custom_element( }, set(value) { - this.$$data[prop] = get_custom_element_value(prop, value, boolean_props); + value = get_custom_element_value(prop, value, props_definition); + this.$$data[prop] = value; if (this.$$component) { this.$$component[prop] = value; } - if(should_reflect.indexOf(typeof value) !== -1 || value == null) { + if(props_definition[prop].reflect) { this.$$reflecting = true; if (value === false || value == null) { this.removeAttribute(prop); } else { - this.setAttribute(prop, value); + this.setAttribute( + props_definition[prop].attribute || prop, + get_custom_element_value(prop, value, props_definition, 'toAttribute') as string + ); } this.$$reflecting = false; } } - }) + }); } - prop_names.forEach((prop) => { + Object.keys(props_definition).forEach((prop) => { createProperty(prop, prop); // will be ce.camcelcase = "foo" const lower = prop.toLowerCase(); if (lower !== prop) { createProperty(lower, prop); } - // also support hyphenated version where will be ce['camel-case'] = "foo" - const hyphen = camelToHyphen(prop); - if (hyphen !== lower) { - createProperty(hyphen, prop) - } }); accessors.forEach(accessor => { 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..392af5f85736 --- /dev/null +++ b/test/custom-elements/samples/camel-case-attribute/main.svelte @@ -0,0 +1,17 @@ + + + + +

Hello {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..6747a77d1380 --- /dev/null +++ b/test/custom-elements/samples/camel-case-attribute/test.js @@ -0,0 +1,24 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + target.innerHTML = ''; + 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]'); + assert.equal(el.shadowRoot.innerHTML, '

Hello universe!

3

4

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

Hello galaxy!

5

6

'); + assert.equal(target.innerHTML, '') + + el.camelcase = 'solar system'; + el.anarray = [7, 8]; + assert.equal(el.shadowRoot.innerHTML, '

Hello solar system!

7

8

'); + 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..9dfd2b8f3a82 --- /dev/null +++ b/test/custom-elements/samples/ce-options-valid/main.svelte @@ -0,0 +1,12 @@ + + + + +

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..75de66d5c608 --- /dev/null +++ b/test/custom-elements/samples/ce-options-valid/test.js @@ -0,0 +1,11 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + 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-svelte-options/test.js b/test/custom-elements/samples/no-svelte-options/test.js index a3c2a4857479..ebb7db4d51b1 100644 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ b/test/custom-elements/samples/no-svelte-options/test.js @@ -3,7 +3,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js index a3c2a4857479..170fbb4f9d98 100644 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ b/test/custom-elements/samples/no-tag-warning/test.js @@ -3,7 +3,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/no-tag/test.js b/test/custom-elements/samples/no-tag/test.js index a3c2a4857479..ddf193bc1cd1 100644 --- a/test/custom-elements/samples/no-tag/test.js +++ b/test/custom-elements/samples/no-tag/test.js @@ -3,7 +3,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, { name: {} }, [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/reflect-attributes/main.svelte b/test/custom-elements/samples/reflect-attributes/main.svelte index 726b0838c9b6..59fadcc3c6a4 100644 --- a/test/custom-elements/samples/reflect-attributes/main.svelte +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -1,8 +1,11 @@ - + diff --git a/test/custom-elements/samples/reflect-attributes/my-widget.svelte b/test/custom-elements/samples/reflect-attributes/my-widget.svelte index 02685c8cb3c3..8c9a2c3de3ca 100644 --- a/test/custom-elements/samples/reflect-attributes/my-widget.svelte +++ b/test/custom-elements/samples/reflect-attributes/my-widget.svelte @@ -1,4 +1,7 @@ - + + + +

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..2fa0eb2a0d77 --- /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..348cd5aa868d --- /dev/null +++ b/test/custom-elements/samples/$$slot-dynamic-content/test.js @@ -0,0 +1,19 @@ +import * as assert from 'assert'; +import Component from './main.svelte'; + +export default function (target) { + const component = new Component({ target, props: { name: 'slot' } }); + + const ce = target.querySelector('my-widget'); + + assert.htmlEqual(ce.shadowRoot.innerHTML, ` + fallback +

named fallback

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

named fallback

+ `); +} From 07af5121ca0e701c527255ea1280096ee5a2c8a3 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 6 Apr 2023 17:56:28 +0200 Subject: [PATCH 06/36] inline styles for custom elements mode --- src/compiler/compile/render_dom/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index 590b0fe65c0f..b4b96ce42e3f 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -49,7 +49,7 @@ export default function dom( const should_add_css = ( !!styles && - options.css === 'injected' + (options.customElement || options.css === 'injected') ); if (should_add_css) { From 62e6d0654dcfb7779a1c49cc892888b45c8164f5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 6 Apr 2023 22:27:29 +0200 Subject: [PATCH 07/36] remove unused styles param --- src/runtime/internal/Component.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index 415224ff6e58..5d2850a65f28 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -313,25 +313,18 @@ interface CustomElementPropDefinition { * @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 styles Additional styles to apply to the shadow root (not needed for Svelte components compiled with `customElement: true`) * @returns A custom element class */ export function create_custom_element( Component: ComponentType, props_definition: Record, slots: string[], - accessors: string[], - styles?: string + accessors: string[] ) { const Class = class extends SvelteElement { constructor() { super(Component, slots); this.$$props_definition = props_definition; - if (styles) { - const style = document.createElement('style'); - style.textContent = styles; - this.shadowRoot!.appendChild(style); - } } static get observedAttributes() { From 868fb234edfe2aefc27caf93b3439c6cda72e25f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 11 Apr 2023 10:11:05 +0200 Subject: [PATCH 08/36] mount and render after a tick --- .../compile/render_dom/wrappers/Slot.ts | 4 --- src/runtime/internal/Component.ts | 36 +++++++------------ test/custom-elements/samples/$$props/test.js | 4 ++- .../samples/$$slot-dynamic-content/test.js | 13 ++++--- test/custom-elements/samples/$$slot/test.js | 4 ++- test/custom-elements/samples/action/test.js | 3 +- .../samples/camel-case-attribute/test.js | 4 ++- .../samples/ce-options-valid/test.js | 4 ++- .../samples/custom-method/test.js | 2 ++ .../samples/escaped-css/test.js | 4 ++- test/custom-elements/samples/events/test.js | 4 ++- .../samples/extended-builtin/test.js | 4 ++- .../samples/html-slots/test.js | 4 ++- test/custom-elements/samples/html/test.js | 4 ++- test/custom-elements/samples/nested/test.js | 2 ++ .../samples/new-styled/test.js | 4 ++- .../samples/no-missing-prop-warnings/test.js | 4 ++- .../samples/no-svelte-options/test.js | 4 ++- .../samples/no-tag-warning/test.js | 4 ++- test/custom-elements/samples/no-tag/test.js | 4 ++- test/custom-elements/samples/oncreate/test.js | 1 + .../custom-elements/samples/ondestroy/test.js | 1 + test/custom-elements/samples/props/test.js | 6 +++- .../samples/reflect-attributes/test.js | 5 ++- 24 files changed, 79 insertions(+), 50 deletions(-) diff --git a/src/compiler/compile/render_dom/wrappers/Slot.ts b/src/compiler/compile/render_dom/wrappers/Slot.ts index d0cb2c318d6f..0a589e339489 100644 --- a/src/compiler/compile/render_dom/wrappers/Slot.ts +++ b/src/compiler/compile/render_dom/wrappers/Slot.ts @@ -132,10 +132,6 @@ 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 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( diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index 5d2850a65f28..9bab5c757538 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -1,4 +1,4 @@ -import { add_render_callback, flush, flush_render_callbacks, schedule_update, dirty_components, tick } from './scheduler'; +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, get_custom_elements_slots, insert } from './dom'; @@ -169,10 +169,17 @@ if (typeof HTMLElement === 'function') { super.addEventListener(type, listener, options); } - connectedCallback() { + async connectedCallback() { this.$$connected = true; if (!this.$$component) { - function create_slot(name: string, $$c_e = false) { + // 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 = { @@ -181,25 +188,15 @@ if (typeof HTMLElement === 'function') { if (name !== 'default') { node.setAttribute('name', name); } - if (typeof obj.$$c_e === 'object') { - (obj.$$c_e as any).c() - } }, m: function mount(target: HTMLElement, anchor?: HTMLElement) { insert(target, node, anchor); - if (typeof obj.$$c_e === 'object') { - (obj.$$c_e as any).m(node, anchor) - } }, d: function destroy(detaching: boolean) { if (detaching) { - if (typeof obj.$$c_e === 'object') { - (obj.$$c_e as any).d(detaching) - } detach(node); } - }, - $$c_e + } }; return obj; }; @@ -213,15 +210,6 @@ if (typeof HTMLElement === 'function') { } } - if (!Object.keys($$slots).length && this.$$slots.length) { - // There are potentially slots, but we didn't find any. This could be due to Svelte adding the child nodes - // only after the component is created (in the mount phase). In this case, pass in placeholder slots. - // The drawback is that all $$slots properties will be `true`, even if the component doesn't use them. - for (const name of this.$$slots) { - $$slots[name] = [create_slot(name, true)]; - } - } - for (const attribute of this.attributes) { // this.$$data takes precedence over this.attributes const name = this.$$get_prop_name(attribute.name); @@ -256,7 +244,7 @@ if (typeof HTMLElement === 'function') { disconnectedCallback() { this.$$connected = false; // In a microtask, because this could be a move within the DOM - tick().then(() => { + Promise.resolve().then(() => { if (!this.$$connected) { this.$$component!.$destroy(); this.$$component = undefined; diff --git a/test/custom-elements/samples/$$props/test.js b/test/custom-elements/samples/$$props/test.js index 94cad865778c..844390acc938 100644 --- a/test/custom-elements/samples/$$props/test.js +++ b/test/custom-elements/samples/$$props/test.js @@ -1,8 +1,10 @@ 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('custom-element'); assert.htmlEqual(el.shadowRoot.innerHTML, ` diff --git a/test/custom-elements/samples/$$slot-dynamic-content/test.js b/test/custom-elements/samples/$$slot-dynamic-content/test.js index 348cd5aa868d..629e6f5eb7f2 100644 --- a/test/custom-elements/samples/$$slot-dynamic-content/test.js +++ b/test/custom-elements/samples/$$slot-dynamic-content/test.js @@ -1,19 +1,22 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import Component from './main.svelte'; -export default function (target) { +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, ` - fallback -

named fallback

+ +

named fallback

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

named fallback

+ +

named fallback

`); } 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/test.js b/test/custom-elements/samples/action/test.js index e3fa0a808f83..e57be4059eda 100644 --- a/test/custom-elements/samples/action/test.js +++ b/test/custom-elements/samples/action/test.js @@ -1,9 +1,10 @@ -import { tick } from 'svelte'; 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']); diff --git a/test/custom-elements/samples/camel-case-attribute/test.js b/test/custom-elements/samples/camel-case-attribute/test.js index 9b0c35d9d9c5..e8933e2c1fd1 100644 --- a/test/custom-elements/samples/camel-case-attribute/test.js +++ b/test/custom-elements/samples/camel-case-attribute/test.js @@ -1,8 +1,10 @@ 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('custom-element'); assert.equal(el.shadowRoot.innerHTML, '

Hello world!

1

2

'); diff --git a/test/custom-elements/samples/ce-options-valid/test.js b/test/custom-elements/samples/ce-options-valid/test.js index 75de66d5c608..9fa19e53a25c 100644 --- a/test/custom-elements/samples/ce-options-valid/test.js +++ b/test/custom-elements/samples/ce-options-valid/test.js @@ -1,8 +1,10 @@ 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('custom-element'); const h1 = el.shadowRoot.querySelector('h1'); diff --git a/test/custom-elements/samples/custom-method/test.js b/test/custom-elements/samples/custom-method/test.js index 08c58c33838d..ba1ca2517643 100644 --- a/test/custom-elements/samples/custom-method/test.js +++ b/test/custom-elements/samples/custom-method/test.js @@ -1,8 +1,10 @@ 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'); await el.updateFoo(42); diff --git a/test/custom-elements/samples/escaped-css/test.js b/test/custom-elements/samples/escaped-css/test.js index e7df08da1cd9..1b7e2ea7ce77 100644 --- a/test/custom-elements/samples/escaped-css/test.js +++ b/test/custom-elements/samples/escaped-css/test.js @@ -1,8 +1,10 @@ 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 icon = target.querySelector('custom-element').shadowRoot.querySelector('.icon'); const before = getComputedStyle(icon, '::before'); diff --git a/test/custom-elements/samples/events/test.js b/test/custom-elements/samples/events/test.js index 2c8b7832d0d6..ec4fcd27964d 100644 --- a/test/custom-elements/samples/events/test.js +++ b/test/custom-elements/samples/events/test.js @@ -1,8 +1,10 @@ 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('custom-element'); const events = []; diff --git a/test/custom-elements/samples/extended-builtin/test.js b/test/custom-elements/samples/extended-builtin/test.js index 1eac8d852eb0..ba5d27ea6d70 100644 --- a/test/custom-elements/samples/extended-builtin/test.js +++ b/test/custom-elements/samples/extended-builtin/test.js @@ -1,8 +1,10 @@ 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(); assert.equal(target.innerHTML, ''); const el = target.querySelector('custom-element'); diff --git a/test/custom-elements/samples/html-slots/test.js b/test/custom-elements/samples/html-slots/test.js index c82a2d24ad90..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'); diff --git a/test/custom-elements/samples/html/test.js b/test/custom-elements/samples/html/test.js index 4e38fd6c2d7e..d7764af397e0 100644 --- a/test/custom-elements/samples/html/test.js +++ b/test/custom-elements/samples/html/test.js @@ -1,8 +1,10 @@ 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('custom-element'); assert.equal(el.name, 'world'); diff --git a/test/custom-elements/samples/nested/test.js b/test/custom-elements/samples/nested/test.js index 29029f3d2c58..0493e58bf01b 100644 --- a/test/custom-elements/samples/nested/test.js +++ b/test/custom-elements/samples/nested/test.js @@ -1,8 +1,10 @@ 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'); diff --git a/test/custom-elements/samples/new-styled/test.js b/test/custom-elements/samples/new-styled/test.js index 343345190007..bf7abb449e6f 100644 --- a/test/custom-elements/samples/new-styled/test.js +++ b/test/custom-elements/samples/new-styled/test.js @@ -1,9 +1,11 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import './main.svelte'; -export default function (target) { +export default async function (target) { target.innerHTML = '

unstyled

'; 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/no-missing-prop-warnings/test.js b/test/custom-elements/samples/no-missing-prop-warnings/test.js index 6f15b639201a..32c447e1c122 100644 --- a/test/custom-elements/samples/no-missing-prop-warnings/test.js +++ b/test/custom-elements/samples/no-missing-prop-warnings/test.js @@ -1,7 +1,8 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import './main.svelte'; -export default function (target) { +export default async function (target) { const warnings = []; const warn = console.warn; @@ -10,6 +11,7 @@ export default function (target) { }; target.innerHTML = ''; + await tick(); assert.deepEqual(warnings, [ " was created without expected prop 'bar'" diff --git a/test/custom-elements/samples/no-svelte-options/test.js b/test/custom-elements/samples/no-svelte-options/test.js index ebb7db4d51b1..0ff4e7c7d284 100644 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ b/test/custom-elements/samples/no-svelte-options/test.js @@ -1,10 +1,12 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; -export default function (target) { +export default async function (target) { customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], [])); target.innerHTML = ''; + await tick(); const el = target.querySelector('no-tag'); const h1 = el.shadowRoot.querySelector('h1'); diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js index 170fbb4f9d98..e6b82e8ec8a8 100644 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ b/test/custom-elements/samples/no-tag-warning/test.js @@ -1,10 +1,12 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; -export default function (target) { +export default async function (target) { customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], [])); target.innerHTML = ''; + await tick(); const el = target.querySelector('no-tag'); const h1 = el.shadowRoot.querySelector('h1'); diff --git a/test/custom-elements/samples/no-tag/test.js b/test/custom-elements/samples/no-tag/test.js index ddf193bc1cd1..4967b31fba5b 100644 --- a/test/custom-elements/samples/no-tag/test.js +++ b/test/custom-elements/samples/no-tag/test.js @@ -1,10 +1,12 @@ import * as assert from 'assert'; +import { tick } from 'svelte'; import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; -export default function (target) { +export default async function (target) { customElements.define('no-tag', create_custom_element(CustomElement, { name: {} }, [], [])); target.innerHTML = ''; + await tick(); const el = target.querySelector('no-tag'); const h1 = el.shadowRoot.querySelector('h1'); diff --git a/test/custom-elements/samples/oncreate/test.js b/test/custom-elements/samples/oncreate/test.js index cd27d9de86c4..d377efe156f3 100644 --- a/test/custom-elements/samples/oncreate/test.js +++ b/test/custom-elements/samples/oncreate/test.js @@ -4,6 +4,7 @@ import './main.svelte'; export default async function (target) { target.innerHTML = ''; + await tick(); const el = target.querySelector('my-app'); await tick(); diff --git a/test/custom-elements/samples/ondestroy/test.js b/test/custom-elements/samples/ondestroy/test.js index 3092613fc3e3..62ec07a419be 100644 --- a/test/custom-elements/samples/ondestroy/test.js +++ b/test/custom-elements/samples/ondestroy/test.js @@ -4,6 +4,7 @@ import './main.svelte'; export default async function (target) { target.innerHTML = ''; + await tick(); const el = target.querySelector('my-app'); target.removeChild(el); diff --git a/test/custom-elements/samples/props/test.js b/test/custom-elements/samples/props/test.js index 3d93c60b83f2..e3824582e1a6 100644 --- a/test/custom-elements/samples/props/test.js +++ b/test/custom-elements/samples/props/test.js @@ -1,8 +1,11 @@ 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(); + await tick(); assert.equal(target.innerHTML, ''); @@ -10,6 +13,7 @@ export default function (target) { const widget = el.shadowRoot.querySelector('my-widget'); const [p1, p2, p3, p4] = widget.shadowRoot.querySelectorAll('p'); + console.log('goooo', !!p1, !!p2, !!p3, !!p4) assert.equal(p1.textContent, '3 items'); assert.equal(p2.textContent, 'a, b, c'); diff --git a/test/custom-elements/samples/reflect-attributes/test.js b/test/custom-elements/samples/reflect-attributes/test.js index 4b392868c6f3..dfa925403dcd 100644 --- a/test/custom-elements/samples/reflect-attributes/test.js +++ b/test/custom-elements/samples/reflect-attributes/test.js @@ -1,8 +1,11 @@ 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(); + await tick(); const ceRoot = target.querySelector('custom-element').shadowRoot; const div = ceRoot.querySelector('div'); const p = ceRoot.querySelector('p'); From a31d4a58c214a48344c833f3d26ae0f55eef3ef1 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 11 Apr 2023 11:03:47 +0200 Subject: [PATCH 09/36] use $set, remove lowercase property handling in favor of attribute conversion --- src/runtime/internal/Component.ts | 27 +++++++------------ test/custom-elements/samples/action/test.js | 1 + .../samples/camel-case-attribute/main.svelte | 4 ++- .../samples/camel-case-attribute/test.js | 25 +++++++++-------- test/custom-elements/samples/props/test.js | 2 +- 5 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index 9bab5c757538..0772d98f6c8a 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -238,7 +238,7 @@ if (typeof HTMLElement === 'function') { attr = this.$$get_prop_name(attr); this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$props_definition, 'toProp'); - this.$$component![attr] = this.$$data[attr]; + this.$$component!.$set({ [attr]: this.$$data[attr] }); } disconnectedCallback() { @@ -253,7 +253,10 @@ if (typeof HTMLElement === 'function') { } private $$get_prop_name(attribute_name: string): string { - return Object.keys(this.$$props_definition).find(key => this.$$props_definition[key].attribute === attribute_name) || attribute_name; + 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; } }; } @@ -316,12 +319,12 @@ export function create_custom_element( } static get observedAttributes() { - return Object.keys(props_definition).map(key => props_definition[key].attribute || key); + return Object.keys(props_definition).map(key => (props_definition[key].attribute || key).toLowerCase()); } }; - function createProperty(name: string, prop: string) { - Object.defineProperty(Class.prototype, name, { + Object.keys(props_definition).forEach((prop) => { + Object.defineProperty(Class.prototype, prop, { get() { return this.$$component && prop in this.$$component ? this.$$component[prop] @@ -331,10 +334,7 @@ export function create_custom_element( set(value) { value = get_custom_element_value(prop, value, props_definition); this.$$data[prop] = value; - - if (this.$$component) { - this.$$component[prop] = value; - } + this.$$component?.$set({ [prop]: value }); if (props_definition[prop].reflect) { this.$$reflecting = true; @@ -351,15 +351,6 @@ export function create_custom_element( } } }); - } - - Object.keys(props_definition).forEach((prop) => { - createProperty(prop, prop); - // will be ce.camcelcase = "foo" - const lower = prop.toLowerCase(); - if (lower !== prop) { - createProperty(lower, prop); - } }); accessors.forEach(accessor => { diff --git a/test/custom-elements/samples/action/test.js b/test/custom-elements/samples/action/test.js index e57be4059eda..4619ae85687b 100644 --- a/test/custom-elements/samples/action/test.js +++ b/test/custom-elements/samples/action/test.js @@ -10,6 +10,7 @@ export default async function (target) { assert.deepEqual(events, ['foo']); el.name = 'bar'; + await tick(); assert.deepEqual(events, ['foo', 'bar']); target.innerHTML = ''; diff --git a/test/custom-elements/samples/camel-case-attribute/main.svelte b/test/custom-elements/samples/camel-case-attribute/main.svelte index 392af5f85736..0738e5160797 100644 --- a/test/custom-elements/samples/camel-case-attribute/main.svelte +++ b/test/custom-elements/samples/camel-case-attribute/main.svelte @@ -2,16 +2,18 @@ tag="custom-element" cePropsDefinition={{ camelCase: { attribute: "camel-case" }, + camelCase2: { reflect: true }, anArray: { attribute: "an-array", type: "Array", reflect: true }, }} /> -

Hello {camelCase}!

+

{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 index e8933e2c1fd1..6a8d044cf711 100644 --- a/test/custom-elements/samples/camel-case-attribute/test.js +++ b/test/custom-elements/samples/camel-case-attribute/test.js @@ -3,24 +3,23 @@ import { tick } from 'svelte'; import './main.svelte'; export default async function (target) { - target.innerHTML = ''; + 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]'); - assert.equal(el.shadowRoot.innerHTML, '

Hello universe!

3

4

'); - assert.equal(target.innerHTML, ''); - + 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]; - assert.equal(el.shadowRoot.innerHTML, '

Hello galaxy!

5

6

'); - assert.equal(target.innerHTML, ''); - - el.camelcase = 'solar system'; - el.anarray = [7, 8]; - assert.equal(el.shadowRoot.innerHTML, '

Hello solar system!

7

8

'); - assert.equal(target.innerHTML, ''); + await tick(); + assert.equal(el.shadowRoot.innerHTML, '

Hey galaxy!

5

6

'); + assert.equal(target.innerHTML, ''); } diff --git a/test/custom-elements/samples/props/test.js b/test/custom-elements/samples/props/test.js index e3824582e1a6..1f50c9be8831 100644 --- a/test/custom-elements/samples/props/test.js +++ b/test/custom-elements/samples/props/test.js @@ -13,7 +13,6 @@ export default async function (target) { const widget = el.shadowRoot.querySelector('my-widget'); const [p1, p2, p3, p4] = widget.shadowRoot.querySelectorAll('p'); - console.log('goooo', !!p1, !!p2, !!p3, !!p4) assert.equal(p1.textContent, '3 items'); assert.equal(p2.textContent, 'a, b, c'); @@ -22,6 +21,7 @@ export default async 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'); From 931b7f556d90d0efc96492e7f522faa3cded3454 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 11 Apr 2023 11:08:49 +0200 Subject: [PATCH 10/36] cePropsDefinition -> ceProps --- elements/index.d.ts | 2 +- src/compiler/compile/Component.ts | 12 ++++++------ src/compiler/compile/compiler_errors.ts | 6 +++--- src/compiler/compile/render_dom/index.ts | 2 +- .../samples/camel-case-attribute/main.svelte | 2 +- .../samples/ce-options-valid/main.svelte | 2 +- .../samples/reflect-attributes/main.svelte | 2 +- .../samples/reflect-attributes/my-widget.svelte | 5 +---- 8 files changed, 15 insertions(+), 18 deletions(-) diff --git a/elements/index.d.ts b/elements/index.d.ts index fcdeacecf8b5..b73d027657da 100644 --- a/elements/index.d.ts +++ b/elements/index.d.ts @@ -1591,7 +1591,7 @@ export interface SvelteHTMLElements { 'svelte:fragment': { slot?: string }; 'svelte:options': { tag?: string | null | undefined; - cePropsDefinition?: Record | undefined, + ceProps?: Record | undefined, [name: string]: any }; 'svelte:head': { [name: string]: any }; diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index 9d42d0e102d3..d21521a91577 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -45,7 +45,7 @@ interface ComponentOptions { immutable?: boolean; accessors?: boolean; preserveWhitespace?: boolean; - cePropsDefinition?: Record; + ceProps?: Record; } const regex_leading_directory_separator = /^[/\\]/; @@ -1574,11 +1574,11 @@ function process_component_options(component: Component, nodes) { break; } - case 'cePropsDefinition': { - const error = () => component.error(attribute, compiler_errors.invalid_cePropsDefinition_attribute); + case 'ceProps': { + const error = () => component.error(attribute, compiler_errors.invalid_ceProps_attribute); const { value } = attribute; const chunk = value[0]; - component_options.cePropsDefinition = {}; + component_options.ceProps = {}; if (!chunk) { break; @@ -1593,7 +1593,7 @@ function process_component_options(component: Component, nodes) { if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression') { return error(); } - component_options.cePropsDefinition[property.key.name] = {}; + component_options.ceProps[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(); @@ -1605,7 +1605,7 @@ function process_component_options(component: Component, nodes) { ) { return error(); } - component_options.cePropsDefinition[property.key.name][prop.key.name] = prop.value.value; + component_options.ceProps[property.key.name][prop.key.name] = prop.value.value; } } diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index fb9130f6406c..c7b855433162 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -206,9 +206,9 @@ 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 " + + invalid_ceProps_attribute: { + code: 'invalid-ceProps-attribute', + message: "'ceProps' 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) => ({ diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index b4b96ce42e3f..8a4fd4437d32 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -544,7 +544,7 @@ export default function dom( 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] || {}; + def[prop.export_name] = component.component_options.ceProps?.[prop.export_name] || {}; if (prop.is_boolean && !def[prop.export_name].type) { def[prop.export_name].type = 'Boolean'; } diff --git a/test/custom-elements/samples/camel-case-attribute/main.svelte b/test/custom-elements/samples/camel-case-attribute/main.svelte index 0738e5160797..bcbc35471cc0 100644 --- a/test/custom-elements/samples/camel-case-attribute/main.svelte +++ b/test/custom-elements/samples/camel-case-attribute/main.svelte @@ -1,6 +1,6 @@ diff --git a/test/custom-elements/samples/reflect-attributes/main.svelte b/test/custom-elements/samples/reflect-attributes/main.svelte index 59fadcc3c6a4..a1d3f67fbc4b 100644 --- a/test/custom-elements/samples/reflect-attributes/main.svelte +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -1,6 +1,6 @@ +

Context {context}

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/test.js b/test/custom-elements/samples/no-svelte-options/test.js index 0ff4e7c7d284..f619c4232b01 100644 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ b/test/custom-elements/samples/no-svelte-options/test.js @@ -4,7 +4,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default async function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], [], true)); target.innerHTML = ''; await tick(); diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js index e6b82e8ec8a8..4558f195f90f 100644 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ b/test/custom-elements/samples/no-tag-warning/test.js @@ -4,7 +4,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default async function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], [], true)); target.innerHTML = ''; await tick(); diff --git a/test/custom-elements/samples/no-tag/test.js b/test/custom-elements/samples/no-tag/test.js index 4967b31fba5b..b364bc6bcc7e 100644 --- a/test/custom-elements/samples/no-tag/test.js +++ b/test/custom-elements/samples/no-tag/test.js @@ -4,7 +4,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default async function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, { name: {} }, [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, { name: {} }, [], [], true)); target.innerHTML = ''; await tick(); diff --git a/test/js/samples/css-shadow-dom-keyframes/expected.js b/test/js/samples/css-shadow-dom-keyframes/expected.js index 0fd49de5f7d5..97c40eca12f9 100644 --- a/test/js/samples/css-shadow-dom-keyframes/expected.js +++ b/test/js/samples/css-shadow-dom-keyframes/expected.js @@ -44,5 +44,5 @@ class Component extends SvelteComponent { } } -customElements.define("custom-element", create_custom_element(Component, {}, [], [])); +customElements.define("custom-element", create_custom_element(Component, {}, [], [], true)); export default Component; \ No newline at end of file From 573784cbc36514d8b38c41c493be42d19eb89e82 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Thu, 13 Apr 2023 06:46:34 -0700 Subject: [PATCH 22/36] chore: run fewer CI jobs (#8496) --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a4d83558f1a..9288f8333c0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,17 @@ jobs: timeout-minutes: 15 strategy: matrix: - node-version: [14, 16, 18] - os: [ubuntu-latest, windows-latest, macOS-latest] + include: + - node-version: 14 + os: ubuntu-latest + - node-version: 14 + os: windows-latest + - node-version: 14 + os: macOS-latest + - node-version: 16 + os: ubuntu-latest + - node-version: 18 + os: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -37,6 +46,7 @@ jobs: timeout-minutes: 10 strategy: matrix: + node-version: 14 os: [ubuntu-latest, windows-latest, macOS-latest] steps: - uses: actions/checkout@v3 From d6bcddd582544ee25e48360443194590ebac68c1 Mon Sep 17 00:00:00 2001 From: Hofer Ivan Date: Fri, 14 Apr 2023 12:28:45 +0200 Subject: [PATCH 23/36] breaking: improve types for `createEventDispatcher` (#7224) --------- Co-authored-by: Simon Holthausen --- .github/workflows/ci.yml | 13 ++++++-- CHANGELOG.md | 4 ++- package.json | 2 +- src/runtime/internal/lifecycle.ts | 31 ++++++++++++------- test/types/create-event-dispatcher.ts | 43 +++++++++++++++++++++++++++ test/types/tsconfig.json | 16 ++++++++++ 6 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 test/types/create-event-dispatcher.ts create mode 100644 test/types/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9288f8333c0b..8520c0431033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,8 +46,17 @@ jobs: timeout-minutes: 10 strategy: matrix: - node-version: 14 - os: [ubuntu-latest, windows-latest, macOS-latest] + include: + - node-version: 14 + os: ubuntu-latest + - node-version: 14 + os: windows-latest + - node-version: 14 + os: macOS-latest + - node-version: 16 + os: ubuntu-latest + - node-version: 18 + os: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b50c6951d3..a6345d99b317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## Unreleased (4.0) -* Minimum supported Node version is now Node 14 +* **breaking** Minimum supported Node version is now Node 14 +* **breaking** Minimum supported TypeScript version is now 5 (it will likely work with lower versions, but we make no guarantess about that) +* **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224)) ## Unreleased (3.0) diff --git a/package.json b/package.json index c5239c8b502e..dc27fa208462 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ }, "types": "types/runtime/index.d.ts", "scripts": { - "test": "npm run test:unit && npm run test:integration", + "test": "npm run test:unit && npm run test:integration && echo \"manually check that there are no type errors in test/types by opening the files in there\"", "test:integration": "mocha --exit", "test:unit": "mocha --config .mocharc.unit.js --exit", "quicktest": "mocha --exit", diff --git a/src/runtime/internal/lifecycle.ts b/src/runtime/internal/lifecycle.ts index e75bbdc501f4..7592e72a3860 100644 --- a/src/runtime/internal/lifecycle.ts +++ b/src/runtime/internal/lifecycle.ts @@ -56,6 +56,14 @@ export function onDestroy(fn: () => any) { get_current_component().$$.on_destroy.push(fn); } +export interface EventDispatcher> { + ( + ...args: [EventMap[Type]] extends [never] ? [type: Type, parameter?: null | undefined, options?: DispatchOptions] : + null extends EventMap[Type] ? [type: Type, parameter?: EventMap[Type], options?: DispatchOptions] : + undefined extends EventMap[Type] ? [type: Type, parameter?: EventMap[Type], options?: DispatchOptions] : + [type: Type, parameter: EventMap[Type], options?: DispatchOptions]): boolean; +} + export interface DispatchOptions { cancelable?: boolean; } @@ -68,20 +76,23 @@ export interface DispatchOptions { * [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent). * These events do not [bubble](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_bubbling_and_capture). * The `detail` argument corresponds to the [CustomEvent.detail](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail) - * property and can contain any type of data. + * property and can contain any type of data. + * + * The event dispatcher can be typed to narrow the allowed event names and the type of the `detail` argument: + * ```ts + * const dispatch = createEventDispatcher<{ + * loaded: never; // does not take a detail argument + * change: string; // takes a detail argument of type string, which is required + * optional: number | null; // takes an optional detail argument of type number + * }>(); + * ``` * * https://svelte.dev/docs#run-time-svelte-createeventdispatcher */ -export function createEventDispatcher(): < - EventKey extends Extract ->( - type: EventKey, - detail?: EventMap[EventKey], - options?: DispatchOptions -) => boolean { +export function createEventDispatcher = any>(): EventDispatcher { const component = get_current_component(); - return (type: string, detail?: any, { cancelable = false } = {}): boolean => { + return ((type: string, detail?: any, { cancelable = false } = {}): boolean => { const callbacks = component.$$.callbacks[type]; if (callbacks) { @@ -95,7 +106,7 @@ export function createEventDispatcher(): < } return true; - }; + }) as EventDispatcher; } /** diff --git a/test/types/create-event-dispatcher.ts b/test/types/create-event-dispatcher.ts new file mode 100644 index 000000000000..d9fc6c65bdce --- /dev/null +++ b/test/types/create-event-dispatcher.ts @@ -0,0 +1,43 @@ +import { createEventDispatcher } from '$runtime/internal/lifecycle'; + +const dispatch = createEventDispatcher<{ + loaded: never + change: string + valid: boolean + optional: number | null +}>(); + +// @ts-expect-error: dispatch invalid event +dispatch('some-event'); + +dispatch('loaded'); +dispatch('loaded', null); +dispatch('loaded', undefined); +dispatch('loaded', undefined, { cancelable: true }); +// @ts-expect-error: no detail accepted +dispatch('loaded', 123); + +// @ts-expect-error: detail not provided +dispatch('change'); +dispatch('change', 'string'); +dispatch('change', 'string', { cancelable: true }); +// @ts-expect-error: wrong type of detail +dispatch('change', 123); +// @ts-expect-error: wrong type of detail +dispatch('change', undefined); + +dispatch('valid', true); +dispatch('valid', true, { cancelable: true }); +// @ts-expect-error: wrong type of detail +dispatch('valid', 'string'); + +dispatch('optional'); +dispatch('optional', 123); +dispatch('optional', 123, { cancelable: true }); +dispatch('optional', null); +dispatch('optional', undefined); +dispatch('optional', undefined, { cancelable: true }); +// @ts-expect-error: wrong type of optional detail +dispatch('optional', 'string'); +// @ts-expect-error: wrong type of option +dispatch('optional', undefined, { cancelabled: true }); diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 000000000000..108ed2a2b29a --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": "../../", + "paths": { + "$runtime/*": ["src/runtime/*"] + }, + // enable strictest options + "allowUnreachableCode": false, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "strict": true, + }, + "include": ["."] +} \ No newline at end of file From 56a6738a5bdb8a0cd36c74852e04241eec1c2318 Mon Sep 17 00:00:00 2001 From: Tan Li Hau Date: Fri, 14 Apr 2023 19:21:20 +0800 Subject: [PATCH 24/36] breaking: conditional ActionReturn type if Parameter is void (#7442) --------- Co-authored-by: Ivan Hofer Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Ignatius Bagus Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> Co-authored-by: Simon Holthausen --- CHANGELOG.md | 1 + src/runtime/action/index.ts | 19 ++-- src/runtime/internal/lifecycle.ts | 3 + test/types/actions.ts | 153 ++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 test/types/actions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a6345d99b317..e28643f1f02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * **breaking** Minimum supported Node version is now Node 14 * **breaking** Minimum supported TypeScript version is now 5 (it will likely work with lower versions, but we make no guarantess about that) * **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)) ## Unreleased (3.0) diff --git a/src/runtime/action/index.ts b/src/runtime/action/index.ts index 388f6f040e20..a672bb7ae5f3 100644 --- a/src/runtime/action/index.ts +++ b/src/runtime/action/index.ts @@ -1,7 +1,8 @@ /** * Actions can return an object containing the two properties defined in this interface. Both are optional. * - update: An action can have a parameter. This method will be called whenever that parameter changes, - * immediately after Svelte has applied updates to the markup. + * immediately after Svelte has applied updates to the markup. `ActionReturn` and `ActionReturn` both + * mean that the action accepts no parameters, which makes it illegal to set the `update` method. * - destroy: Method that is called after the element is unmounted * * Additionally, you can specify which additional attributes and events the action enables on the applied element. @@ -25,8 +26,8 @@ * * Docs: https://svelte.dev/docs#template-syntax-element-directives-use-action */ -export interface ActionReturn = Record> { - update?: (parameter: Parameter) => void; +export interface ActionReturn = Record> { + update?: [Parameter] extends [never] ? never : (parameter: Parameter) => void; destroy?: () => void; /** * ### DO NOT USE THIS @@ -42,15 +43,21 @@ export interface ActionReturn` elements * and optionally accepts a parameter which it has a default value for: * ```ts - * export const myAction: Action = (node, param = { someProperty: true }) => { + * export const myAction: Action = (node, param = { someProperty: true }) => { * // ... * } * ``` + * `Action` and `Action` both signal that the action accepts no parameters. + * * You can return an object with methods `update` and `destroy` from the function and type which additional attributes and events it has. * See interface `ActionReturn` for more details. * * Docs: https://svelte.dev/docs#template-syntax-element-directives-use-action */ -export interface Action = Record> { - (node: Node, parameter?: Parameter): void | ActionReturn; +export interface Action = Record> { + (...args: [Parameter] extends [never] ? [node: Node] : undefined extends Parameter ? [node: Node, parameter?: Parameter] : [node: Node, parameter: Parameter]): void | ActionReturn; } + +// Implementation notes: +// - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode +// - [X] extends [never] is needed, X extends never would reduce the whole resulting type to never and not to one of the condition outcomes diff --git a/src/runtime/internal/lifecycle.ts b/src/runtime/internal/lifecycle.ts index 7592e72a3860..29888e9de3da 100644 --- a/src/runtime/internal/lifecycle.ts +++ b/src/runtime/internal/lifecycle.ts @@ -57,6 +57,9 @@ export function onDestroy(fn: () => any) { } export interface EventDispatcher> { + // Implementation notes: + // - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode + // - [X] extends [never] is needed, X extends never would reduce the whole resulting type to never and not to one of the condition outcomes ( ...args: [EventMap[Type]] extends [never] ? [type: Type, parameter?: null | undefined, options?: DispatchOptions] : null extends EventMap[Type] ? [type: Type, parameter?: EventMap[Type], options?: DispatchOptions] : diff --git a/test/types/actions.ts b/test/types/actions.ts new file mode 100644 index 000000000000..2a604151a85b --- /dev/null +++ b/test/types/actions.ts @@ -0,0 +1,153 @@ +import type { Action, ActionReturn } from '$runtime/action'; + +// ---------------- Action + +const href: Action = (node) => { + node.href = ''; + // @ts-expect-error + node.href = 1; +}; +href; + +const required: Action = (node, param) => { + node; + param; +}; +required(null as any, true); +// @ts-expect-error (only in strict mode) boolean missing +required(null as any); +// @ts-expect-error no boolean +required(null as any, 'string'); + +const required1: Action = (node, param) => { + node; + param; + return { + update: (p) => p === true, + destroy: () => {} + }; +}; +required1; + +const required2: Action = (node) => { + node; +}; +required2; + +const required3: Action = (node, param) => { + node; + param; + return { + // @ts-expect-error comparison always resolves to false + update: (p) => p === 'd', + destroy: () => {} + }; +}; +required3; + +const optional: Action = (node, param?) => { + node; + param; +}; +optional(null as any, true); +optional(null as any); +// @ts-expect-error no boolean +optional(null as any, 'string'); + +const optional1: Action = (node, param?) => { + node; + param; + return { + update: (p) => p === true, + destroy: () => {} + }; +}; +optional1; + +const optional2: Action = (node) => { + node; +}; +optional2; + +const optional3: Action = (node, param) => { + node; + param; +}; +optional3; + +const optional4: Action = (node, param?) => { + node; + param; + return { + // @ts-expect-error comparison always resolves to false + update: (p) => p === 'd', + destroy: () => {} + }; +}; +optional4; + +const no: Action = (node) => { + node; +}; +// @ts-expect-error second param +no(null as any, true); +no(null as any); +// @ts-expect-error second param +no(null as any, 'string'); + +const no1: Action = (node) => { + node; + return { + destroy: () => {} + }; +}; +no1; + +// @ts-expect-error param given +const no2: Action = (node, param?) => {}; +no2; + +// @ts-expect-error param given +const no3: Action = (node, param) => {}; +no3; + +// @ts-expect-error update method given +const no4: Action = (node) => { + return { + update: () => {}, + destroy: () => {} + }; +}; +no4; + +// ---------------- ActionReturn + +const requiredReturn: ActionReturn = { + update: (p) => p.toString() +}; +requiredReturn; + +const optionalReturn: ActionReturn = { + update: (p) => { + p === true; + // @ts-expect-error could be undefined + p.toString(); + } +}; +optionalReturn; + +const invalidProperty: ActionReturn = { + // @ts-expect-error invalid property + invalid: () => {} +}; +invalidProperty; + +type Attributes = ActionReturn['$$_attributes']; +const attributes: Attributes = { a: 'a' }; +attributes; +// @ts-expect-error wrong type +const invalidAttributes1: Attributes = { a: 1 }; +invalidAttributes1; +// @ts-expect-error missing prop +const invalidAttributes2: Attributes = {}; +invalidAttributes2; From 94606169905b9764789f127917aa8fc23cf61a96 Mon Sep 17 00:00:00 2001 From: Tim McCabe Date: Fri, 14 Apr 2023 08:51:04 -0400 Subject: [PATCH 25/36] feat: add `a11y-no-static-element-interactions` compiler rule (#8251) Ref: #820 --- src/compiler/compile/compiler_warnings.ts | 4 +++ src/compiler/compile/nodes/Element.ts | 31 +++++++++++++++++-- src/compiler/compile/utils/a11y.ts | 9 ++++-- .../input.svelte | 17 ++++++++++ .../warnings.json | 28 ++++++++--------- .../input.svelte | 6 ++++ .../warnings.json | 16 +++++----- .../input.svelte | 18 +++++++++++ .../warnings.json | 26 ++++++++++++++++ .../samples/slot-warning-ignore/input.svelte | 1 + .../samples/slot-warning/input.svelte | 1 + .../samples/slot-warning/warnings.json | 4 +-- .../samples/slot-warning2/input.svelte | 1 + .../samples/slot-warning2/warnings.json | 4 +-- 14 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 test/validator/samples/a11y-no-static-element-interactions/input.svelte create mode 100644 test/validator/samples/a11y-no-static-element-interactions/warnings.json diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index a851bc24c2c8..e9fc80cabef4 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -115,6 +115,10 @@ export default { code: 'a11y-no-redundant-roles', message: `A11y: Redundant role '${role}'` }), + a11y_no_static_element_interactions: (element: string, handlers: string[]) => ({ + code: 'a11y-no-static-element-interactions', + message: `A11y: <${element}> with ${handlers.join(', ')} ${handlers.length === 1 ? 'handler' : 'handlers'} must have an ARIA role` + }), a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({ code: 'a11y-no-interactive-element-to-noninteractive-role', message: `A11y: <${element}> cannot have role '${role}'` diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 2410904d6301..3e6d886a5d35 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -738,8 +738,10 @@ export default class Element extends Node { } } + const role = attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey; + // no-noninteractive-tabindex - if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) { + if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role)) { const tab_index = attribute_map.get('tabindex'); if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) { component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex); @@ -747,8 +749,7 @@ export default class Element extends Node { } // role-supports-aria-props - const role = attribute_map.get('role'); - const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey; + const role_value = (role ?? get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey; if (typeof role_value === 'string' && roles.has(role_value)) { const { props } = roles.get(role_value); const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props))); @@ -762,6 +763,30 @@ export default class Element extends Node { } }); } + + const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static; + + // no-static-element-interactions + if ( + !has_dynamic_role && + !is_hidden_from_screen_reader(this.name, attribute_map) && + !is_presentation_role(role) && + !is_interactive_element(this.name, attribute_map) && + !is_interactive_roles(role) && + !is_non_interactive_element(this.name, attribute_map) && + !is_non_interactive_roles(role) && + !is_abstract_role(role) + ) { + const interactive_handlers = handlers + .map((handler) => handler.name) + .filter((handlerName) => a11y_interactive_handlers.has(handlerName)); + if (interactive_handlers.length > 0) { + component.warn( + this, + compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers) + ); + } + } } validate_special_cases() { diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index 4409f802623d..bc23f5c81866 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -19,7 +19,8 @@ const non_interactive_roles = new Set( // 'toolbar' does not descend from widget, but it does support // aria-activedescendant, thus in practice we treat it as a widget. // focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable. - !['toolbar', 'tabpanel'].includes(name) && + // 'generic' is meant to have no semantic meaning. + !['toolbar', 'tabpanel', 'generic'].includes(name) && !role.superClass.some((classes) => classes.includes('widget')) ); }) @@ -31,7 +32,11 @@ const non_interactive_roles = new Set( ); const interactive_roles = new Set( - non_abstract_roles.filter((name) => !non_interactive_roles.has(name)) + non_abstract_roles.filter((name) => + !non_interactive_roles.has(name) && + // 'generic' is meant to have no semantic meaning. + name !== 'generic' + ) ); export function is_non_interactive_roles(role: ARIARoleDefinitionKey) { diff --git a/test/validator/samples/a11y-click-events-have-key-events/input.svelte b/test/validator/samples/a11y-click-events-have-key-events/input.svelte index 8737f04ec586..c6ac9ac86616 100644 --- a/test/validator/samples/a11y-click-events-have-key-events/input.svelte +++ b/test/validator/samples/a11y-click-events-have-key-events/input.svelte @@ -9,12 +9,16 @@ +
+
+
+