import type { MockedFunction } from 'vitest' import type { VaporElement } from '../src/apiDefineVaporCustomElement' import { type HMRRuntime, type Ref, inject, nextTick, onMounted, provide, ref, toDisplayString, useHost, useShadowRoot, } from '@vue/runtime-dom' import { VaporTeleport, child, createComponent, createIf, createPlainElement, createSlot, createVaporApp, defineVaporAsyncComponent, defineVaporComponent, defineVaporCustomElement, delegateEvents, next, on, renderEffect, setInsertionState, setText, setValue, template, txt, withVaporCtx, } from '../src' declare var __VUE_HMR_RUNTIME__: HMRRuntime describe('defineVaporCustomElement', () => { const container = document.createElement('div') document.body.appendChild(container) beforeEach(() => { container.innerHTML = '' }) delegateEvents('input', 'click', 'mousedown') function render(tag: string, props: any) { const root = document.createElement('div') document.body.appendChild(root) createVaporApp({ setup() { return createPlainElement(tag, props, null, true) }, }).mount(root) return { container: root, } } describe('mounting/unmount', () => { const E = defineVaporCustomElement({ props: { msg: { type: String, default: 'hello', }, }, setup(props: any) { const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, toDisplayString(props.msg))) return n0 }, }) customElements.define('my-element', E) test('should work', () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement expect(e).toBeInstanceOf(E) expect(e._instance).toBeTruthy() expect(e.shadowRoot!.innerHTML).toBe(`
hello
`) }) test('should work w/ manual instantiation', () => { const e = new E({ msg: 'inline' }) // should lazy init expect(e._instance).toBe(null) // should initialize on connect container.appendChild(e) expect(e._instance).toBeTruthy() expect(e.shadowRoot!.innerHTML).toBe(`
inline
`) }) test('should unmount on remove', async () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement container.removeChild(e) await nextTick() expect(e._instance).toBe(null) expect(e.shadowRoot!.innerHTML).toBe('') }) test('When elements move, avoid prematurely disconnecting MutationObserver', async () => { const CustomInput = defineVaporCustomElement({ props: ['value'], emits: ['update'], setup(props: any, { emit }: any) { const n0 = template('', true)() as any n0.$evtinput = () => { const num = (n0 as HTMLInputElement).valueAsNumber emit('update', Number.isNaN(num) ? null : num) } renderEffect(() => { setValue(n0, props.value) }) return n0 }, }) customElements.define('my-el-input', CustomInput) const num = ref('12') const containerComp = defineVaporComponent({ setup() { const n1 = template('
', true)() as any setInsertionState(n1, 0, true) createPlainElement('my-el-input', { value: () => num.value, onInput: () => ($event: CustomEvent) => { num.value = $event.detail[0] }, }) return n1 }, }) const app = createVaporApp(containerComp) const container = document.createElement('div') document.body.appendChild(container) app.mount(container) const myInputEl = container.querySelector('my-el-input')! const inputEl = myInputEl.shadowRoot!.querySelector('input')! await nextTick() expect(inputEl.value).toBe('12') const moveEl = container.querySelector('#move')! moveEl.append(myInputEl) await nextTick() myInputEl.removeAttribute('value') await nextTick() expect(inputEl.value).toBe('') }) test('should not unmount on move', async () => { container.innerHTML = `
` const e = container.childNodes[0].childNodes[0] as VaporElement const i = e._instance // moving from one parent to another - this will trigger both disconnect // and connected callbacks synchronously container.appendChild(e) await nextTick() // should be the same instance expect(e._instance).toBe(i) expect(e.shadowRoot!.innerHTML).toBe('
hello
') }) test('remove then insert again', async () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement container.removeChild(e) await nextTick() expect(e._instance).toBe(null) expect(e.shadowRoot!.innerHTML).toBe('') container.appendChild(e) expect(e._instance).toBeTruthy() expect(e.shadowRoot!.innerHTML).toBe('
hello
') }) }) describe('props', () => { const E = defineVaporCustomElement({ props: { foo: [String, null], bar: Object, bazQux: null, value: null, }, setup(props: any) { const n0 = template('
', true)() as any const x0 = txt(n0) as any const n1 = template('
', true)() as any const x1 = txt(n1) as any renderEffect(() => setText(x0, props.foo || '')) renderEffect(() => setText(x1, props.bazQux || (props.bar && props.bar.x)), ) return [n0, n1] }, }) customElements.define('my-el-props', E) test('renders custom element w/ correct object prop value', () => { const { container } = render('my-el-props', { value: () => ({ x: 1, }), }) const el = container.children[0] expect((el as any).value).toEqual({ x: 1 }) }) test('props via attribute', async () => { // bazQux should map to `baz-qux` attribute container.innerHTML = `` const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe('
hello
bye
') // change attr e.setAttribute('foo', 'changed') await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
changed
bye
') e.setAttribute('baz-qux', 'changed') await nextTick() expect(e.shadowRoot!.innerHTML).toBe( '
changed
changed
', ) }) test('props via properties', async () => { // TODO remove this after type inference done const e = new E() as any e.foo = 'one' e.bar = { x: 'two' } container.appendChild(e) expect(e.shadowRoot!.innerHTML).toBe('
one
two
') // reflect // should reflect primitive value expect(e.getAttribute('foo')).toBe('one') // should not reflect rich data expect(e.hasAttribute('bar')).toBe(false) e.foo = 'three' await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
three
two
') expect(e.getAttribute('foo')).toBe('three') e.foo = null await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
two
') expect(e.hasAttribute('foo')).toBe(false) e.foo = undefined await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
two
') expect(e.hasAttribute('foo')).toBe(false) expect(e.foo).toBe(undefined) e.bazQux = 'four' await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
four
') expect(e.getAttribute('baz-qux')).toBe('four') }) test('props via attributes and properties changed together', async () => { // TODO remove this after type inference done const e = new E() as any e.foo = 'foo1' e.bar = { x: 'bar1' } container.appendChild(e) await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
foo1
bar1
') // change attr then property e.setAttribute('foo', 'foo2') e.bar = { x: 'bar2' } await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
foo2
bar2
') expect(e.getAttribute('foo')).toBe('foo2') expect(e.hasAttribute('bar')).toBe(false) // change prop then attr e.bar = { x: 'bar3' } e.setAttribute('foo', 'foo3') await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
foo3
bar3
') expect(e.getAttribute('foo')).toBe('foo3') expect(e.hasAttribute('bar')).toBe(false) }) test('props via hyphen property', async () => { const Comp = defineVaporCustomElement({ props: { fooBar: Boolean, }, setup() { return template('Comp')() }, }) customElements.define('my-el-comp', Comp) const { container } = render('my-el-comp', { 'foo-bar': () => true, }) const el = container.children[0] expect((el as any).outerHTML).toBe('') }) test('attribute -> prop type casting', async () => { const E = defineVaporCustomElement({ props: { fooBar: Number, // test casting of camelCase prop names bar: Boolean, baz: String, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => { const texts = [] texts.push( toDisplayString(props.fooBar), toDisplayString(typeof props.fooBar), toDisplayString(props.bar), toDisplayString(typeof props.bar), toDisplayString(props.baz), toDisplayString(typeof props.baz), ) setText(n0, texts.join(' ')) }) return n0 }, }) customElements.define('my-el-props-cast', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe( `1 number false boolean 12345 string`, ) e.setAttribute('bar', '') await nextTick() expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean 12345 string`) e.setAttribute('foo-bar', '2e1') await nextTick() expect(e.shadowRoot!.innerHTML).toBe( `20 number true boolean 12345 string`, ) e.setAttribute('baz', '2e1') await nextTick() expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean 2e1 string`) }) test('attr casting w/ programmatic creation', () => { const E = defineVaporCustomElement({ props: { foo: Number, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => { setText(n0, `foo type: ${typeof props.foo}`) }) return n0 }, }) customElements.define('my-element-programmatic', E) const el = document.createElement('my-element-programmatic') as any el.setAttribute('foo', '123') container.appendChild(el) expect(el.shadowRoot.innerHTML).toBe(`foo type: number`) }) test('handling properties set before upgrading', () => { const E = defineVaporCustomElement({ props: { foo: String, dataAge: Number, }, setup(props: any) { expect(props.foo).toBe('hello') expect(props.dataAge).toBe(5) const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, `foo: ${props.foo}`)) return n0 }, }) const el = document.createElement('my-el-upgrade') as any el.foo = 'hello' el.dataset.age = 5 el.notProp = 1 container.appendChild(el) customElements.define('my-el-upgrade', E) expect(el.shadowRoot.firstChild.innerHTML).toBe(`foo: hello`) // should not reflect if not declared as a prop expect(el.hasAttribute('not-prop')).toBe(false) }) test('handle properties set before connecting', () => { const obj = { a: 1 } const E = defineVaporCustomElement({ props: { foo: String, post: Object, }, setup(props: any) { expect(props.foo).toBe('hello') expect(props.post).toBe(obj) const n0 = template(' ', true)() as any renderEffect(() => setText(n0, JSON.stringify(props.post))) return n0 }, }) customElements.define('my-el-preconnect', E) const el = document.createElement('my-el-preconnect') as any el.foo = 'hello' el.post = obj container.appendChild(el) expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj)) }) test('handle components with no props', async () => { const E = defineVaporCustomElement({ setup() { return template('
foo
', true)() }, }) customElements.define('my-element-noprops', E) const el = document.createElement('my-element-noprops') container.appendChild(el) await nextTick() expect(el.shadowRoot!.innerHTML).toMatchInlineSnapshot('"
foo
"') }) test('set number value in dom property', () => { const E = defineVaporCustomElement({ props: { 'max-age': Number, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => { setText(n0, `max age: ${props.maxAge}/type: ${typeof props.maxAge}`) }) return n0 }, }) customElements.define('my-element-number-property', E) const el = document.createElement('my-element-number-property') as any container.appendChild(el) el.maxAge = 50 expect(el.maxAge).toBe(50) expect(el.shadowRoot.innerHTML).toBe('max age: 50/type: number') }) test('should reflect default value', () => { const E = defineVaporCustomElement({ props: { value: { type: String, default: 'hi', }, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => setText(n0, props.value)) return n0 }, }) customElements.define('my-el-default-val', E) container.innerHTML = `` const e = container.childNodes[0] as any expect(e.value).toBe('hi') }) test('Boolean prop with default true', async () => { const E = defineVaporCustomElement({ props: { foo: { type: Boolean, default: true, }, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => setText(n0, String(props.foo))) return n0 }, }) customElements.define('my-el-default-true', E) container.innerHTML = `` const e = container.childNodes[0] as HTMLElement & { foo: any }, shadowRoot = e.shadowRoot as ShadowRoot expect(shadowRoot.innerHTML).toBe('true') e.foo = undefined await nextTick() expect(shadowRoot.innerHTML).toBe('true') e.foo = false await nextTick() expect(shadowRoot.innerHTML).toBe('false') e.foo = null await nextTick() expect(shadowRoot.innerHTML).toBe('null') e.foo = '' await nextTick() expect(shadowRoot.innerHTML).toBe('true') }) test('support direct setup function syntax with extra options', () => { const E = defineVaporCustomElement( (props: any) => { const n0 = template(' ')() as any renderEffect(() => setText(n0, props.text)) return n0 }, { props: { text: String, }, }, ) customElements.define('my-el-setup-with-props', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe('hello') }) test('prop types validation', async () => { const E = defineVaporCustomElement({ props: { num: { type: [Number, String], }, bool: { type: Boolean, }, }, setup(props: any) { const n0 = template( '
', true, )() as any const n1 = child(n0) as any const n2 = next(n1) as any const x0 = txt(n1) as any const x1 = txt(n2) as any renderEffect(() => setText(x0, `${props.num} is ${typeof props.num}`)) renderEffect(() => setText(x1, `${props.bool} is ${typeof props.bool}`), ) return n0 }, }) customElements.define('my-el-with-type-props', E) const { container } = render('my-el-with-type-props', { num: () => 1, bool: () => true, }) const e = container.childNodes[0] as VaporElement // @ts-expect-error expect(e.num).toBe(1) // @ts-expect-error expect(e.bool).toBe(true) expect(e.shadowRoot!.innerHTML).toBe( '
1 is numbertrue is boolean
', ) }) }) describe('attrs', () => { const E = defineVaporCustomElement({ setup(_: any, { attrs }: any) { const n0 = template('
')() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, toDisplayString(attrs.foo))) return [n0] }, }) customElements.define('my-el-attrs', E) test('attrs via attribute', async () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe('
hello
') e.setAttribute('foo', 'changed') await nextTick() expect(e.shadowRoot!.innerHTML).toBe('
changed
') }) test('non-declared properties should not show up in $attrs', () => { const e = new E() // @ts-expect-error e.foo = '123' container.appendChild(e) expect(e.shadowRoot!.innerHTML).toBe('
') }) // https://github.com/vuejs/core/issues/12964 // Disabled because of missing support for `delegatesFocus` in jsdom // https://github.com/jsdom/jsdom/issues/3418 // use vitest browser mode instead test.todo('shadowRoot should be initialized with delegatesFocus', () => { const E = defineVaporCustomElement( { setup() { return template('', true)() }, }, { shadowRootOptions: { delegatesFocus: true } } as any, ) customElements.define('my-el-with-delegate-focus', E) const e = new E() container.appendChild(e) expect(e.shadowRoot!.delegatesFocus).toBe(true) }) }) describe('emits', () => { const CompDef = defineVaporComponent({ setup(_, { emit }) { emit('created') const n0 = template('
', true)() as any n0.$evtclick = () => { emit('my-click', 1) } n0.$evtmousedown = () => { emit('myEvent', 1) // validate hyphenation } on(n0, 'wheel', () => { emit('my-wheel', { bubbles: true }, 1) }) return n0 }, }) const E = defineVaporCustomElement(CompDef) customElements.define('my-el-emits', E) test('emit on connect', () => { const e = new E() const spy = vi.fn() e.addEventListener('created', spy) container.appendChild(e) expect(spy).toHaveBeenCalled() }) test('emit on interaction', () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement const spy = vi.fn() e.addEventListener('my-click', spy) // Use click() method which triggers a real click event // with bubbles: true and composed: true ;(e.shadowRoot!.childNodes[0] as HTMLElement).click() expect(spy).toHaveBeenCalledTimes(1) expect(spy.mock.calls[0][0]).toMatchObject({ detail: [1], }) }) test('case transform for camelCase event', () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement const spy1 = vi.fn() e.addEventListener('myEvent', spy1) const spy2 = vi.fn() // emitting myEvent, but listening for my-event. This happens when // using the custom element in a Vue template e.addEventListener('my-event', spy2) e.shadowRoot!.childNodes[0].dispatchEvent( new CustomEvent('mousedown', { bubbles: true, composed: true, }), ) expect(spy1).toHaveBeenCalledTimes(1) expect(spy2).toHaveBeenCalledTimes(1) }) test('emit from within async component wrapper', async () => { const p = new Promise(res => res(CompDef as any)) const E = defineVaporCustomElement(defineVaporAsyncComponent(() => p)) customElements.define('my-async-el-emits', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement const spy = vi.fn() e.addEventListener('my-click', spy) // this feels brittle but seems necessary to reach the node in the DOM. await customElements.whenDefined('my-async-el-emits') await nextTick() await nextTick() e.shadowRoot!.childNodes[0].dispatchEvent( new CustomEvent('click', { bubbles: true, composed: true, }), ) expect(spy).toHaveBeenCalled() expect(spy.mock.calls[0][0]).toMatchObject({ detail: [1], }) }) test('emit in an async component wrapper with properties bound', async () => { const E = defineVaporCustomElement( defineVaporAsyncComponent( () => new Promise(res => res(CompDef as any)), ), ) customElements.define('my-async-el-props-emits', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement const spy = vi.fn() e.addEventListener('my-click', spy) await customElements.whenDefined('my-async-el-props-emits') await nextTick() await nextTick() e.shadowRoot!.childNodes[0].dispatchEvent( new CustomEvent('click', { bubbles: true, composed: true, }), ) expect(spy).toHaveBeenCalled() expect(spy.mock.calls[0][0]).toMatchObject({ detail: [1], }) }) test('emit with options', async () => { container.innerHTML = `` const e = container.childNodes[0] as VaporElement const spy = vi.fn() e.addEventListener('my-wheel', spy) e.shadowRoot!.childNodes[0].dispatchEvent( new CustomEvent('wheel', { bubbles: true, composed: true, }), ) expect(spy).toHaveBeenCalledTimes(1) expect(spy.mock.calls[0][0]).toMatchObject({ bubbles: true, detail: [{ bubbles: true }, 1], }) }) }) describe('slots', () => { const E = defineVaporCustomElement({ setup() { const t0 = template('
fallback
') const t1 = template('
') const n3 = t1() as any setInsertionState(n3, null, true) createSlot('default', null, () => { const n2 = t0() return n2 }) const n5 = t1() as any setInsertionState(n5, null, true) createSlot('named', null) return [n3, n5] }, }) customElements.define('my-el-slots', E) test('render slots correctly', () => { container.innerHTML = `hi` const e = container.childNodes[0] as VaporElement // native slots allocation does not affect innerHTML, so we just // verify that we've rendered the correct native slots here... expect(e.shadowRoot!.innerHTML).toBe( `
` + `
fallback
` + `
` + `
` + `` + `
`, ) }) test('render slot props', async () => { const foo = ref('foo') const E = defineVaporCustomElement({ setup() { const n0 = template('
')() as any setInsertionState(n0, null) createSlot('default', { class: () => foo.value }) return [n0] }, }) customElements.define('my-el-slot-props', E) container.innerHTML = `hi` const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe( `
`, ) foo.value = 'bar' await nextTick() expect(e.shadowRoot!.innerHTML).toBe( `
`, ) }) }) describe('provide/inject', () => { const Consumer = defineVaporCustomElement({ setup() { const foo = inject('foo')! const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, toDisplayString(foo.value))) return n0 }, }) customElements.define('my-consumer', Consumer) test('over nested usage', async () => { const foo = ref('injected!') const Provider = defineVaporCustomElement({ setup() { provide('foo', foo) return createPlainElement('my-consumer') }, }) customElements.define('my-provider', Provider) container.innerHTML = `` const provider = container.childNodes[0] as VaporElement const consumer = provider.shadowRoot!.childNodes[0] as VaporElement expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) foo.value = 'changed!' await nextTick() expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) }) test('over slot composition', async () => { const foo = ref('injected!') const Provider = defineVaporCustomElement({ setup() { provide('foo', foo) return createSlot('default', null) }, }) customElements.define('my-provider-2', Provider) container.innerHTML = `` const provider = container.childNodes[0] const consumer = provider.childNodes[0] as VaporElement expect(consumer.shadowRoot!.innerHTML).toBe(`
injected!
`) foo.value = 'changed!' await nextTick() expect(consumer.shadowRoot!.innerHTML).toBe(`
changed!
`) }) test('inherited from ancestors', async () => { const fooA = ref('FooA!') const fooB = ref('FooB!') const ProviderA = defineVaporCustomElement({ setup() { provide('fooA', fooA) return createPlainElement('provider-b') }, }) const ProviderB = defineVaporCustomElement({ setup() { provide('fooB', fooB) return createPlainElement('my-multi-consumer') }, }) const Consumer = defineVaporCustomElement({ setup() { const fooA = inject('fooA')! const fooB = inject('fooB')! const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, `${fooA.value} ${fooB.value}`)) return n0 }, }) customElements.define('provider-a', ProviderA) customElements.define('provider-b', ProviderB) customElements.define('my-multi-consumer', Consumer) container.innerHTML = `` const providerA = container.childNodes[0] as VaporElement const providerB = providerA.shadowRoot!.childNodes[0] as VaporElement const consumer = providerB.shadowRoot!.childNodes[0] as VaporElement expect(consumer.shadowRoot!.innerHTML).toBe(`
FooA! FooB!
`) fooA.value = 'changedA!' fooB.value = 'changedB!' await nextTick() expect(consumer.shadowRoot!.innerHTML).toBe( `
changedA! changedB!
`, ) }) test('inherited from app context within nested elements', async () => { const outerValues: (string | undefined)[] = [] const innerValues: (string | undefined)[] = [] const innerChildValues: (string | undefined)[] = [] const Outer = defineVaporCustomElement( { setup() { outerValues.push( inject('shared'), inject('outer'), inject('inner'), ) const n0 = template('
', true)() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, }, { configureApp(app: any) { app.provide('shared', 'shared') app.provide('outer', 'outer') }, } as any, ) const Inner = defineVaporCustomElement( { setup() { // ensure values are not self-injected provide('inner', 'inner-child') innerValues.push( inject('shared'), inject('outer'), inject('inner'), ) const n0 = template('
', true)() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, }, { configureApp(app: any) { app.provide('outer', 'override-outer') app.provide('inner', 'inner') }, } as any, ) const InnerChild = defineVaporCustomElement({ setup() { innerChildValues.push( inject('shared'), inject('outer'), inject('inner'), ) const n0 = template('
', true)() as any return n0 }, }) customElements.define('provide-from-app-outer', Outer) customElements.define('provide-from-app-inner', Inner) customElements.define('provide-from-app-inner-child', InnerChild) container.innerHTML = '' + '' + '' + '' + '' const outer = container.childNodes[0] as VaporElement expect(outer.shadowRoot!.innerHTML).toBe( '
', ) expect('[Vue warn]: injection "inner" not found.').toHaveBeenWarnedTimes( 1, ) expect( '[Vue warn]: App already provides property with key "outer" inherited from its parent element. ' + 'It will be overwritten with the new value.', ).toHaveBeenWarnedTimes(1) expect(outerValues).toEqual(['shared', 'outer', undefined]) expect(innerValues).toEqual(['shared', 'override-outer', 'inner']) expect(innerChildValues).toEqual([ 'shared', 'override-outer', 'inner-child', ]) }) }) describe('styles', () => { function assertStyles(el: VaporElement, css: string[]) { const styles = el.shadowRoot?.querySelectorAll('style')! expect(styles.length).toBe(css.length) // should not duplicate multiple copies from Bar for (let i = 0; i < css.length; i++) { expect(styles[i].textContent).toBe(css[i]) } } test('should attach styles to shadow dom', async () => { const def = defineVaporComponent({ __hmrId: 'foo', styles: [`div { color: red; }`], setup() { return template('
hello
', true)() }, } as any) const Foo = defineVaporCustomElement(def) customElements.define('my-el-with-styles', Foo) container.innerHTML = `` const el = container.childNodes[0] as VaporElement const style = el.shadowRoot?.querySelector('style')! expect(style.textContent).toBe(`div { color: red; }`) // hmr __VUE_HMR_RUNTIME__.reload('foo', { ...def, styles: [`div { color: blue; }`, `div { color: yellow; }`], } as any) await nextTick() assertStyles(el, [`div { color: blue; }`, `div { color: yellow; }`]) }) test("child components should inject styles to root element's shadow root", async () => { const Baz = () => createComponent(Bar) const Bar = defineVaporComponent({ __hmrId: 'bar', styles: [`div { color: green; }`, `div { color: blue; }`], setup() { return template('bar')() }, } as any) const Foo = defineVaporCustomElement({ styles: [`div { color: red; }`], setup() { return [createComponent(Baz), createComponent(Baz)] }, }) customElements.define('my-el-with-child-styles', Foo) container.innerHTML = `` const el = container.childNodes[0] as VaporElement // inject order should be child -> parent assertStyles(el, [ `div { color: green; }`, `div { color: blue; }`, `div { color: red; }`, ]) // hmr __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, { ...Bar, styles: [`div { color: red; }`, `div { color: yellow; }`], } as any) await nextTick() assertStyles(el, [ `div { color: red; }`, `div { color: yellow; }`, `div { color: red; }`, ]) __VUE_HMR_RUNTIME__.reload(Bar.__hmrId!, { ...Bar, styles: [`div { color: blue; }`], } as any) await nextTick() assertStyles(el, [`div { color: blue; }`, `div { color: red; }`]) }) test("child components should not inject styles to root element's shadow root w/ shadowRoot false", async () => { const Bar = defineVaporComponent({ styles: [`div { color: green; }`], setup() { return template('bar')() }, } as any) const Baz = () => createComponent(Bar) const Foo = defineVaporCustomElement( { setup() { return [createComponent(Baz)] }, }, { shadowRoot: false } as any, ) customElements.define('my-foo-with-shadowroot-false', Foo) container.innerHTML = `` const el = container.childNodes[0] as VaporElement const style = el.shadowRoot?.querySelector('style') expect(style).toBeUndefined() }) test('with nonce', () => { const Foo = defineVaporCustomElement( { styles: [`div { color: red; }`], setup() { return template('
hello
', true)() }, }, { nonce: 'xxx' } as any, ) customElements.define('my-el-with-nonce', Foo) container.innerHTML = `` const el = container.childNodes[0] as VaporElement const style = el.shadowRoot?.querySelector('style')! expect(style.getAttribute('nonce')).toBe('xxx') }) }) describe('async', () => { test('should work', async () => { const loaderSpy = vi.fn() const E = defineVaporCustomElement( defineVaporAsyncComponent(() => { loaderSpy() return Promise.resolve({ props: ['msg'], styles: [`div { color: red }`], setup(props: any) { const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, props.msg)) return n0 }, }) }), ) customElements.define('my-el-async', E) container.innerHTML = `` + `` await new Promise(r => setTimeout(r)) // loader should be called only once expect(loaderSpy).toHaveBeenCalledTimes(1) const e1 = container.childNodes[0] as VaporElement const e2 = container.childNodes[1] as VaporElement // should inject styles expect(e1.shadowRoot!.innerHTML).toBe( `
hello
`, ) expect(e2.shadowRoot!.innerHTML).toBe( `
world
`, ) // attr e1.setAttribute('msg', 'attr') await nextTick() expect((e1 as any).msg).toBe('attr') expect(e1.shadowRoot!.innerHTML).toBe( `
attr
`, ) // props expect(`msg` in e1).toBe(true) ;(e1 as any).msg = 'prop' expect(e1.getAttribute('msg')).toBe('prop') expect(e1.shadowRoot!.innerHTML).toBe( `
prop
`, ) }) test('set DOM property before resolve', async () => { const E = defineVaporCustomElement( defineVaporAsyncComponent(() => { return Promise.resolve({ props: ['msg'], setup(props: any) { expect(typeof props.msg).toBe('string') const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, props.msg)) return n0 }, }) }), ) customElements.define('my-el-async-2', E) const e1 = new E() as any // set property before connect e1.msg = 'hello' const e2 = new E() as any container.appendChild(e1) container.appendChild(e2) // set property after connect but before resolve e2.msg = 'world' await new Promise(r => setTimeout(r)) expect(e1.shadowRoot!.innerHTML).toBe(`
hello
`) expect(e2.shadowRoot!.innerHTML).toBe(`
world
`) e1.msg = 'world' expect(e1.shadowRoot!.innerHTML).toBe(`
world
`) e2.msg = 'hello' expect(e2.shadowRoot!.innerHTML).toBe(`
hello
`) }) test('Number prop casting before resolve', async () => { const E = defineVaporCustomElement( defineVaporAsyncComponent(() => { return Promise.resolve({ props: { n: Number }, setup(props: any) { expect(props.n).toBe(20) const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, `${props.n},${typeof props.n}`)) return n0 }, }) }), ) customElements.define('my-el-async-3', E) container.innerHTML = `` await new Promise(r => setTimeout(r)) const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe(`
20,number
`) }) test('with slots', async () => { const E = defineVaporCustomElement( defineVaporAsyncComponent(() => { return Promise.resolve({ setup() { const t0 = template('
fallback
') const t1 = template('
') const n3 = t1() as any setInsertionState(n3, null) createSlot('default', null, () => { const n2 = t0() return n2 }) const n5 = t1() as any setInsertionState(n5, null) createSlot('named', null) return [n3, n5] }, }) }), ) customElements.define('my-el-async-slots', E) container.innerHTML = `hi` await new Promise(r => setTimeout(r)) const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe( `
` + `
fallback
` + `
` + `` + `
`, ) }) }) describe('shadowRoot: false', () => { const E = defineVaporCustomElement({ shadowRoot: false, props: { msg: { type: String, default: 'hello', }, }, setup(props: any) { const n0 = template('
')() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, toDisplayString(props.msg))) return n0 }, }) customElements.define('my-el-shadowroot-false', E) test('should work', async () => { function raf() { return new Promise(resolve => { requestAnimationFrame(resolve) }) } container.innerHTML = `` const e = container.childNodes[0] as VaporElement await raf() expect(e).toBeInstanceOf(E) expect(e._instance).toBeTruthy() expect(e.innerHTML).toBe(`
hello
`) expect(e.shadowRoot).toBe(null) }) const toggle = ref(true) const ES = defineVaporCustomElement( { setup() { const n0 = createSlot('default') const n1 = createIf( () => toggle.value, () => createSlot('named'), ) const n2 = createSlot('omitted', null, () => template('
fallback
')(), ) return [n0, n1, n2] }, }, { shadowRoot: false } as any, ) customElements.define('my-el-shadowroot-false-slots', ES) test('should render slots', async () => { container.innerHTML = `` + `defaulttext` + `
named
` + `
` const e = container.childNodes[0] as VaporElement // native slots allocation does not affect innerHTML, so we just // verify that we've rendered the correct native slots here... expect(e.innerHTML).toBe( `defaulttext` + `
named
` + `
fallback
`, ) toggle.value = false await nextTick() expect(e.innerHTML).toBe( `defaulttext` + `` + `
fallback
`, ) }) test('render nested customElement w/ shadowRoot false', async () => { const calls: string[] = [] const Child = defineVaporCustomElement( { setup() { calls.push('child rendering') onMounted(() => { calls.push('child mounted') }) return createSlot('default') }, }, { shadowRoot: false } as any, ) customElements.define('my-child', Child) const Parent = defineVaporCustomElement( { setup() { calls.push('parent rendering') onMounted(() => { calls.push('parent mounted') }) return createSlot('default') }, }, { shadowRoot: false } as any, ) customElements.define('my-parent', Parent) const App = { setup() { return createPlainElement('my-parent', null, { default: withVaporCtx(() => createPlainElement('my-child', null, { default: withVaporCtx(() => template('default')()), }), ), }) }, } const app = createVaporApp(App) app.mount(container) await nextTick() const e = container.childNodes[0] as VaporElement expect(e.innerHTML).toBe( `default`, ) expect(calls).toEqual([ 'parent rendering', 'parent mounted', 'child rendering', 'child mounted', ]) app.unmount() }) test('render nested Teleport w/ shadowRoot false', async () => { const target = document.createElement('div') const Child = defineVaporCustomElement( { setup() { return createComponent( VaporTeleport, { to: () => target }, { default: withVaporCtx(() => createSlot('default')), }, ) }, }, { shadowRoot: false } as any, ) customElements.define('my-el-teleport-child', Child) const Parent = defineVaporCustomElement( { setup() { return createSlot('default') }, }, { shadowRoot: false } as any, ) customElements.define('my-el-teleport-parent', Parent) const App = { setup() { return createPlainElement('my-el-teleport-parent', null, { default: withVaporCtx(() => createPlainElement('my-el-teleport-child', null, { default: withVaporCtx(() => template('default')()), }), ), }) }, } const app = createVaporApp(App) app.mount(container) await nextTick() expect(target.innerHTML).toBe(`default`) app.unmount() }) test('render two Teleports w/ shadowRoot false', async () => { const target1 = document.createElement('div') const target2 = document.createElement('span') const Child = defineVaporCustomElement( { setup() { return [ createComponent( VaporTeleport, { to: () => target1 }, { default: withVaporCtx(() => createSlot('header')), }, ), createComponent( VaporTeleport, { to: () => target2 }, { default: withVaporCtx(() => createSlot('body')), }, ), ] }, }, { shadowRoot: false } as any, ) customElements.define('my-el-two-teleport-child', Child) const App = { setup() { return createPlainElement('my-el-two-teleport-child', null, { default: withVaporCtx(() => [ template('
header
')(), template('body')(), ]), }) }, } const app = createVaporApp(App) app.mount(container) await nextTick() expect(target1.outerHTML).toBe( `
header
`, ) expect(target2.outerHTML).toBe( `body`, ) app.unmount() }) test('render two Teleports w/ shadowRoot false (with disabled)', async () => { const target1 = document.createElement('div') const target2 = document.createElement('span') const Child = defineVaporCustomElement( { setup() { return [ createComponent( VaporTeleport, // with disabled: true { to: () => target1, disabled: () => true }, { default: withVaporCtx(() => createSlot('header')), }, ), createComponent( VaporTeleport, { to: () => target2 }, { default: withVaporCtx(() => createSlot('body')), }, ), ] }, }, { shadowRoot: false } as any, ) customElements.define('my-el-two-teleport-child-0', Child) const App = { setup() { return createPlainElement('my-el-two-teleport-child-0', null, { default: withVaporCtx(() => [ template('
header
')(), template('body')(), ]), }) }, } const app = createVaporApp(App) app.mount(container) await nextTick() expect(target1.outerHTML).toBe(`
`) expect(target2.outerHTML).toBe( `body`, ) app.unmount() }) test('toggle nested custom element with shadowRoot: false', async () => { customElements.define( 'my-el-child-shadow-false', defineVaporCustomElement( { setup() { const n0 = template('
')() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, }, { shadowRoot: false } as any, ), ) const ChildWrapper = { setup() { return createPlainElement('my-el-child-shadow-false', null, { default: withVaporCtx(() => template('child')()), }) }, } customElements.define( 'my-el-parent-shadow-false', defineVaporCustomElement( { props: { isShown: { type: Boolean, required: true }, }, setup(props: any) { return createIf( () => props.isShown, () => { const n0 = template('
')() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, ) }, }, { shadowRoot: false } as any, ), ) const ParentWrapper = { props: { isShown: { type: Boolean, required: true }, }, setup(props: any) { return createPlainElement( 'my-el-parent-shadow-false', { isShown: () => props.isShown }, { default: withVaporCtx(() => createSlot('default')), }, ) }, } const isShown = ref(true) const App = { setup() { return createComponent( ParentWrapper, { isShown: () => isShown.value }, { default: withVaporCtx(() => createComponent(ChildWrapper)), }, ) }, } const container = document.createElement('div') document.body.appendChild(container) const app = createVaporApp(App) app.mount(container) expect(container.innerHTML).toBe( `` + `
` + `` + `
child
` + `
` + `
` + `
`, ) isShown.value = false await nextTick() expect(container.innerHTML).toBe( ``, ) isShown.value = true await nextTick() expect(container.innerHTML).toBe( `` + `
` + `` + `
child
` + `
` + `
` + `
`, ) }) }) describe('helpers', () => { test('useHost', () => { const Foo = defineVaporCustomElement({ setup() { const host = useHost()! host.setAttribute('id', 'host') return template('
hello
')() }, }) customElements.define('my-el-use-host', Foo) container.innerHTML = `` const el = container.childNodes[0] as VaporElement expect(el.id).toBe('host') }) test('useShadowRoot for style injection', () => { const Foo = defineVaporCustomElement({ setup() { const root = useShadowRoot()! const style = document.createElement('style') style.innerHTML = `div { color: red; }` root.appendChild(style) return template('
hello
')() }, }) customElements.define('my-el-use-shadow-root', Foo) container.innerHTML = `` const el = container.childNodes[0] as VaporElement const style = el.shadowRoot?.querySelector('style')! expect(style.textContent).toBe(`div { color: red; }`) }) }) describe('expose', () => { test('expose w/ options api', async () => { const E = defineVaporCustomElement({ setup(_: any, { expose }: any) { const value = ref(0) const foo = () => { value.value++ } expose({ foo }) const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, `${value.value}`)) return n0 }, }) customElements.define('my-el-expose-options-api', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement & { foo: () => void } expect(e.shadowRoot!.innerHTML).toBe(`
0
`) e.foo() await nextTick() expect(e.shadowRoot!.innerHTML).toBe(`
1
`) }) test('expose attributes and callback', async () => { type SetValue = (value: string) => void let fn: MockedFunction const E = defineVaporCustomElement({ setup(_: any, { expose }: any) { const value = ref('hello') const setValue = (fn = vi.fn((_value: string) => { value.value = _value })) expose({ setValue, value, }) const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, value.value)) return n0 }, }) customElements.define('my-el-expose', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement & { value: string setValue: MockedFunction } expect(e.shadowRoot!.innerHTML).toBe(`
hello
`) expect(e.value).toBe('hello') expect(e.setValue).toBe(fn!) e.setValue('world') expect(e.value).toBe('world') await nextTick() expect(e.shadowRoot!.innerHTML).toBe(`
world
`) }) test('warning when exposing an existing property', () => { const E = defineVaporCustomElement({ props: { value: String, }, setup(props: any, { expose }: any) { expose({ value: 'hello', }) const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, props.value)) return n0 }, }) customElements.define('my-el-expose-two', E) container.innerHTML = `` expect( `[Vue warn]: Exposed property "value" already exists on custom element.`, ).toHaveBeenWarned() }) }) test('async & nested custom elements', async () => { let fooVal: string | undefined = '' const E = defineVaporCustomElement( defineVaporAsyncComponent(() => { return Promise.resolve({ setup() { provide('foo', 'foo') const n0 = template('
')() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, }) }), ) const EChild = defineVaporCustomElement({ setup() { fooVal = inject('foo') const n0 = template('
child
')() return n0 }, }) customElements.define('my-el-async-nested-ce', E) customElements.define('slotted-child', EChild) container.innerHTML = `
` await new Promise(r => setTimeout(r)) const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe(`
`) expect(fooVal).toBe('foo') }) test('async & multiple levels of nested custom elements', async () => { let fooVal: string | undefined = '' let barVal: string | undefined = '' const E = defineVaporCustomElement( defineVaporAsyncComponent(() => { return Promise.resolve({ setup() { provide('foo', 'foo') const n0 = template('
')() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, }) }), ) const EChild = defineVaporCustomElement({ setup() { provide('bar', 'bar') const n0 = template('
')() as any setInsertionState(n0, null) createSlot('default', null) return n0 }, }) const EChild2 = defineVaporCustomElement({ setup() { fooVal = inject('foo') barVal = inject('bar') const n0 = template('
child
')() return n0 }, }) customElements.define('my-el-async-nested-m-ce', E) customElements.define('slotted-child-m', EChild) customElements.define('slotted-child2-m', EChild2) container.innerHTML = `` + `
` + `` + `
` + `
` await new Promise(r => setTimeout(r)) const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe(`
`) expect(fooVal).toBe('foo') expect(barVal).toBe('bar') }) describe('configureApp', () => { test('should work', () => { const E = defineVaporCustomElement( () => { const msg = inject('msg') const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, msg as string)) return n0 }, { configureApp(app: any) { app.provide('msg', 'app-injected') }, } as any, ) customElements.define('my-element-with-app', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement expect(e.shadowRoot?.innerHTML).toBe('
app-injected
') }) test('work with async component', async () => { const AsyncComp = defineVaporAsyncComponent(() => { return Promise.resolve({ setup() { const msg = inject('msg') const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, msg as string)) return n0 }, } as any) }) const E = defineVaporCustomElement(AsyncComp, { configureApp(app: any) { app.provide('msg', 'app-injected') }, } as any) customElements.define('my-async-element-with-app', E) container.innerHTML = `` const e = container.childNodes[0] as VaporElement await new Promise(r => setTimeout(r)) expect(e.shadowRoot?.innerHTML).toBe('
app-injected
') }) test('with hmr reload', async () => { const __hmrId = '__hmrWithApp' const def = defineVaporComponent({ __hmrId, setup() { const msg = inject('msg') const n0 = template('
')() as any const n1 = child(n0) as any const x1 = txt(n1) as any renderEffect(() => setText(x1, msg as string)) return n0 }, }) const E = defineVaporCustomElement(def, { configureApp(app: any) { app.provide('msg', 'app-injected') }, } as any) customElements.define('my-element-with-app-hmr', E) container.innerHTML = `` const el = container.childNodes[0] as VaporElement expect(el.shadowRoot?.innerHTML).toBe( `
app-injected
`, ) // hmr __VUE_HMR_RUNTIME__.reload(__hmrId, def as any) await nextTick() expect(el.shadowRoot?.innerHTML).toBe( `
app-injected
`, ) }) }) // #9885 // test('avoid double mount when prop is set immediately after mount', () => { // customElements.define( // 'my-input-dupe', // defineVaporCustomElement({ // props: { // value: String, // }, // render() { // return 'hello' // }, // }), // ) // const container = document.createElement('div') // document.body.appendChild(container) // createVaporApp({ // // render() { // // return h('div', [ // // h('my-input-dupe', { // // onVnodeMounted(vnode) { // // vnode.el!.value = 'fesfes' // // }, // // }), // // ]) // // }, // setup() { // // const n0 = template('
')() as any // } // }).mount(container) // expect(container.children[0].children[0].shadowRoot?.innerHTML).toBe( // 'hello', // ) // }) test('Props can be casted when mounting custom elements in component rendering functions', async () => { const E = defineVaporCustomElement( defineVaporAsyncComponent(() => Promise.resolve({ props: ['fooValue'], setup(props: any) { expect(props.fooValue).toBe('fooValue') const n0 = template('
', true)() as any const x0 = txt(n0) as any renderEffect(() => setText(x0, props.fooValue)) return n0 }, }), ), ) customElements.define('my-el-async-4', E) const R = defineVaporComponent({ setup() { const fooValue = ref('fooValue') const n0 = template('
')() as any setInsertionState(n0, null) createPlainElement('my-el-async-4', { fooValue: () => fooValue.value, }) return n0 }, }) const app = createVaporApp(R) app.mount(container) await new Promise(r => setTimeout(r)) const e = container.querySelector('my-el-async-4') as VaporElement expect(e.shadowRoot!.innerHTML).toBe(`
fooValue
`) app.unmount() }) test('delete prop on attr removal', async () => { const E = defineVaporCustomElement({ props: { boo: { type: Boolean, }, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => setText(n0, `${props.boo},${typeof props.boo}`)) return n0 }, }) customElements.define('el-attr-removal', E) container.innerHTML = '' const e = container.childNodes[0] as VaporElement expect(e.shadowRoot!.innerHTML).toBe(`true,boolean`) e.removeAttribute('boo') await nextTick() expect(e.shadowRoot!.innerHTML).toBe(`false,boolean`) }) test('hyphenated attr removal', async () => { const E = defineVaporCustomElement({ props: { fooBar: { type: Boolean, }, }, setup(props: any) { const n0 = template(' ')() as any renderEffect(() => setText(n0, toDisplayString(props.fooBar))) return n0 }, }) customElements.define('el-hyphenated-attr-removal', E) const toggle = ref(true) const { container } = render('el-hyphenated-attr-removal', { 'foo-bar': () => (toggle.value ? '' : null), }) const el = container.children[0] expect(el.hasAttribute('foo-bar')).toBe(true) expect((el as any).outerHTML).toBe( ``, ) toggle.value = false await nextTick() expect(el.hasAttribute('foo-bar')).toBe(false) expect((el as any).outerHTML).toBe( ``, ) }) test('no unexpected mutation of the 1st argument', () => { const Foo = { __vapor: true, name: 'Foo', } defineVaporCustomElement(Foo, { shadowRoot: false } as any) expect(Foo).toEqual({ __vapor: true, name: 'Foo', }) }) })