Skip to content
51 changes: 39 additions & 12 deletions packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,22 @@ describe('defineCustomElement', () => {
})
customElements.define('my-element', E)

test('should work', () => {
test('should work', async () => {
container.innerHTML = `<my-element></my-element>`
const e = container.childNodes[0] as VueElement
await nextTick()
expect(e).toBeInstanceOf(E)
expect(e._instance).toBeTruthy()
expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
})

test('should work w/ manual instantiation', () => {
test('should work w/ manual instantiation', async () => {
const e = new E({ msg: 'inline' })
// should lazy init
expect(e._instance).toBe(null)
// should initialize on connect
container.appendChild(e)
await nextTick()
expect(e._instance).toBeTruthy()
expect(e.shadowRoot!.innerHTML).toBe(`<div>inline</div>`)
})
Expand Down Expand Up @@ -98,6 +100,7 @@ describe('defineCustomElement', () => {
})
const app = createApp(containerComp)
app.mount(container)
await nextTick()
const myInputEl = container.querySelector('my-el-input')!
const inputEl = myInputEl.shadowRoot!.querySelector('input')!
await nextTick()
Expand All @@ -112,6 +115,7 @@ describe('defineCustomElement', () => {

test('should not unmount on move', async () => {
container.innerHTML = `<div><my-element></my-element></div>`
await nextTick()
const e = container.childNodes[0].childNodes[0] as VueElement
const i = e._instance
// moving from one parent to another - this will trigger both disconnect
Expand Down Expand Up @@ -157,6 +161,7 @@ describe('defineCustomElement', () => {
test('props via attribute', async () => {
// bazQux should map to `baz-qux` attribute
container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
await nextTick()
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')

Expand All @@ -177,6 +182,7 @@ describe('defineCustomElement', () => {
e.foo = 'one'
e.bar = { x: 'two' }
container.appendChild(e)
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')

// reflect
Expand Down Expand Up @@ -227,6 +233,7 @@ describe('defineCustomElement', () => {
})
customElements.define('my-el-props-cast', E)
container.innerHTML = `<my-el-props-cast foo-bar="1" baz="12345"></my-el-props-cast>`
await nextTick()
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe(
`1 number false boolean 12345 string`,
Expand All @@ -248,7 +255,7 @@ describe('defineCustomElement', () => {
})

// #4772
test('attr casting w/ programmatic creation', () => {
test('attr casting w/ programmatic creation', async () => {
const E = defineCustomElement({
props: {
foo: Number,
Expand All @@ -261,10 +268,11 @@ describe('defineCustomElement', () => {
const el = document.createElement('my-element-programmatic') as any
el.setAttribute('foo', '123')
container.appendChild(el)
await nextTick()
expect(el.shadowRoot.innerHTML).toBe(`foo type: number`)
})

test('handling properties set before upgrading', () => {
test('handling properties set before upgrading', async () => {
const E = defineCustomElement({
props: {
foo: String,
Expand All @@ -284,12 +292,13 @@ describe('defineCustomElement', () => {
el.notProp = 1
container.appendChild(el)
customElements.define('my-el-upgrade', E)
await nextTick()
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', () => {
test('handle properties set before connecting', async () => {
const obj = { a: 1 }
const E = defineCustomElement({
props: {
Expand All @@ -310,6 +319,7 @@ describe('defineCustomElement', () => {
el.post = obj

container.appendChild(el)
await nextTick()
expect(el.shadowRoot.innerHTML).toBe(JSON.stringify(obj))
})

Expand All @@ -328,7 +338,7 @@ describe('defineCustomElement', () => {
})

// # 5793
test('set number value in dom property', () => {
test('set number value in dom property', async () => {
const E = defineCustomElement({
props: {
'max-age': Number,
Expand All @@ -341,12 +351,13 @@ describe('defineCustomElement', () => {
customElements.define('my-element-number-property', E)
const el = document.createElement('my-element-number-property') as any
container.appendChild(el)
await nextTick()
el.maxAge = 50
expect(el.maxAge).toBe(50)
expect(el.shadowRoot.innerHTML).toBe('max age: 50/type: number')
})

test('support direct setup function syntax with extra options', () => {
test('support direct setup function syntax with extra options', async () => {
const E = defineCustomElement(
props => {
return () => props.text
Expand All @@ -359,6 +370,7 @@ describe('defineCustomElement', () => {
)
customElements.define('my-el-setup-with-props', E)
container.innerHTML = `<my-el-setup-with-props text="hello"></my-el-setup-with-props>`
await nextTick()
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe('hello')
})
Expand All @@ -374,6 +386,7 @@ describe('defineCustomElement', () => {

test('attrs via attribute', async () => {
container.innerHTML = `<my-el-attrs foo="hello"></my-el-attrs>`
await nextTick()
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')

Expand All @@ -382,11 +395,12 @@ describe('defineCustomElement', () => {
expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div>')
})

test('non-declared properties should not show up in $attrs', () => {
test('non-declared properties should not show up in $attrs', async () => {
const e = new E()
// @ts-expect-error
e.foo = '123'
container.appendChild(e)
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div></div>')
})
})
Expand All @@ -409,16 +423,18 @@ describe('defineCustomElement', () => {
const E = defineCustomElement(CompDef)
customElements.define('my-el-emits', E)

test('emit on connect', () => {
test('emit on connect', async () => {
const e = new E()
const spy = vi.fn()
e.addEventListener('created', spy)
container.appendChild(e)
await nextTick()
expect(spy).toHaveBeenCalled()
})

test('emit on interaction', () => {
test('emit on interaction', async () => {
container.innerHTML = `<my-el-emits></my-el-emits>`
await nextTick()
const e = container.childNodes[0] as VueElement
const spy = vi.fn()
e.addEventListener('my-click', spy)
Expand All @@ -430,8 +446,9 @@ describe('defineCustomElement', () => {
})

// #5373
test('case transform for camelCase event', () => {
test('case transform for camelCase event', async () => {
container.innerHTML = `<my-el-emits></my-el-emits>`
await nextTick()
const e = container.childNodes[0] as VueElement
const spy1 = vi.fn()
e.addEventListener('myEvent', spy1)
Expand All @@ -449,6 +466,7 @@ describe('defineCustomElement', () => {
const E = defineCustomElement(defineAsyncComponent(() => p))
customElements.define('my-async-el-emits', E)
container.innerHTML = `<my-async-el-emits></my-async-el-emits>`
await nextTick()
const e = container.childNodes[0] as VueElement
const spy = vi.fn()
e.addEventListener('my-click', spy)
Expand All @@ -471,6 +489,7 @@ describe('defineCustomElement', () => {
)
customElements.define('my-async-el-props-emits', E)
container.innerHTML = `<my-async-el-props-emits id="my_async_el_props_emits"></my-async-el-props-emits>`
await nextTick()
const e = container.childNodes[0] as VueElement
const spy = vi.fn()
e.addEventListener('my-click', spy)
Expand Down Expand Up @@ -500,8 +519,9 @@ describe('defineCustomElement', () => {
})
customElements.define('my-el-slots', E)

test('default slot', () => {
test('default slot', async () => {
container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
await nextTick()
const e = container.childNodes[0] as VueElement
// native slots allocation does not affect innerHTML, so we just
// verify that we've rendered the correct native slots here...
Expand Down Expand Up @@ -532,6 +552,8 @@ describe('defineCustomElement', () => {
})
customElements.define('my-provider', Provider)
container.innerHTML = `<my-provider><my-provider>`
await nextTick()
await nextTick()
const provider = container.childNodes[0] as VueElement
const consumer = provider.shadowRoot!.childNodes[0] as VueElement

Expand All @@ -555,6 +577,7 @@ describe('defineCustomElement', () => {
customElements.define('my-provider-2', Provider)

container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
await nextTick()
const provider = container.childNodes[0]
const consumer = provider.childNodes[0] as VueElement
expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
Expand Down Expand Up @@ -596,6 +619,10 @@ describe('defineCustomElement', () => {
customElements.define('provider-b', ProviderB)
customElements.define('my-multi-consumer', Consumer)
container.innerHTML = `<provider-a><provider-a>`
// three components nested, so three ticks are needed
await nextTick()
await nextTick()
await nextTick()
const providerA = container.childNodes[0] as VueElement
const providerB = providerA.shadowRoot!.childNodes[0] as VueElement
const consumer = providerB.shadowRoot!.childNodes[0] as VueElement
Expand Down
7 changes: 5 additions & 2 deletions packages/runtime-dom/src/apiCustomElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,11 @@ export class VueElement extends BaseClass {
// apply CSS
this._applyStyles(styles)

// initial render
this._update()
// #9885 - nextTick fixes duplication issue with v-model
nextTick(() => {
// initial render
this._update()
})
}

const asyncDef = (this._def as ComponentOptions).__asyncLoader
Expand Down
2 changes: 1 addition & 1 deletion packages/sfc-playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const sfcOptions = computed(
template: {
isProd: productionMode.value,
compilerOptions: {
isCustomElement: (tag: string) => tag === 'mjx-container',
isCustomElement: (tag: string) => tag === 'mjx-container' || tag.startsWith('my-'),
Copy link
Author

Choose a reason for hiding this comment

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

This can be removed once reviews are complete, I just wanted to add a quick way to show the fix in the SFC playground for reviewers.

},
},
}),
Expand Down