diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts
index df438d47eee..c0474768266 100644
--- a/packages/runtime-dom/__tests__/customElement.spec.ts
+++ b/packages/runtime-dom/__tests__/customElement.spec.ts
@@ -145,6 +145,62 @@ describe('defineCustomElement', () => {
expect(e._instance).toBeTruthy()
expect(e.shadowRoot!.innerHTML).toBe('
hello
')
})
+
+ // #12412
+ const contextElStyle = ':host { color: red }'
+ const ContextEl = defineCustomElement({
+ props: {
+ msg: String,
+ },
+ styles: [contextElStyle],
+ setup(props, { expose }) {
+ expose({
+ text: () => props.msg,
+ })
+ provide('context', props)
+ const context = inject('context', {}) as typeof props
+ return () => context.msg || props.msg
+ },
+ })
+ customElements.define('my-context-el', ContextEl)
+ test('remove element with child custom element and wait fully disconnected then append and change attribute', async () => {
+ container.innerHTML = `
`
+ const parent = container.children[0].children[0] as VueElement & {
+ text: () => string
+ }
+ const child = parent.children[0] as VueElement
+ parent.remove()
+ await nextTick()
+ await nextTick() // wait two ticks for disconnect
+ expect('text' in parent).toBe(false)
+ expect(child.shadowRoot!.querySelectorAll('style').length).toBe(1)
+ container.appendChild(parent) // should not throw Error
+ await nextTick()
+ expect(parent.text()).toBe('msg1')
+ expect(parent.shadowRoot!.textContent).toBe(contextElStyle + 'msg1')
+ expect(child.shadowRoot!.textContent).toBe(contextElStyle + 'msg1')
+ parent.setAttribute('msg', 'msg2')
+ await nextTick()
+ expect(parent.shadowRoot!.textContent).toBe(contextElStyle + 'msg2')
+ await nextTick()
+ expect(child.shadowRoot!.textContent).toBe(contextElStyle + 'msg2')
+ expect(child.shadowRoot!.querySelectorAll('style').length).toBe(1)
+ })
+
+ test('move element to new parent', async () => {
+ container.innerHTML = ``
+ const first = container.children[0] as VueElement,
+ second = container.children[1] as VueElement & { text: () => string }
+ await nextTick()
+ expect(second.shadowRoot!.textContent).toBe(contextElStyle + 'msg2')
+ first.append(second)
+ await nextTick()
+ expect(second.shadowRoot!.textContent).toBe(contextElStyle + 'msg1')
+ expect(second.shadowRoot!.querySelectorAll('style').length).toBe(1)
+ second.setAttribute('msg', 'msg3')
+ await nextTick()
+ expect(second.text()).toBe('msg3')
+ })
})
describe('props', () => {
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
index aeeaeec9b9f..b2b2d3f2011 100644
--- a/packages/runtime-dom/src/apiCustomElement.ts
+++ b/packages/runtime-dom/src/apiCustomElement.ts
@@ -286,20 +286,25 @@ export class VueElement
this._connected = true
// locate nearest Vue custom element parent for provide/inject
- let parent: Node | null = this
+ let parent: Node | null = this,
+ parentChanged = false
while (
(parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
) {
if (parent instanceof VueElement) {
+ parentChanged = parent !== this._parent
this._parent = parent
break
}
}
- if (!this._instance) {
+ // unmount if parent changed and previously mounted, should keep parent and observer
+ if (this._instance && parentChanged) this._unmount(true)
+ if (!this._instance || parentChanged) {
if (this._resolved) {
- this._setParent()
- this._update()
+ // no instance means no observer
+ if (!this._instance) this._observe()
+ this._mount(this._def)
} else {
if (parent && parent._pendingResolve) {
this._pendingResolve = parent._pendingResolve.then(() => {
@@ -320,22 +325,47 @@ export class VueElement
}
}
+ private _unmount(keepParentAndOb?: boolean) {
+ if (!keepParentAndOb) {
+ this._parent = undefined
+ if (this._ob) {
+ this._ob.disconnect()
+ this._ob = null
+ }
+ }
+ this._app && this._app.unmount()
+ if (this._instance) {
+ const exposed = this._instance.exposed
+ if (exposed) {
+ for (const key in exposed) {
+ delete this[key as keyof this]
+ }
+ }
+ this._instance.ce = undefined
+ }
+ this._app = this._instance = null
+ }
+
disconnectedCallback(): void {
this._connected = false
nextTick(() => {
if (!this._connected) {
- if (this._ob) {
- this._ob.disconnect()
- this._ob = null
- }
- // unmount
- this._app && this._app.unmount()
- if (this._instance) this._instance.ce = undefined
- this._app = this._instance = null
+ this._unmount()
}
})
}
+ private _observe() {
+ if (!this._ob) {
+ this._ob = new MutationObserver(mutations => {
+ for (const m of mutations) {
+ this._setAttr(m.attributeName!)
+ }
+ })
+ }
+ this._ob.observe(this, { attributes: true })
+ }
+
/**
* resolve inner component definition (handle possible async component)
*/
@@ -350,13 +380,7 @@ export class VueElement
}
// watch future attr changes
- this._ob = new MutationObserver(mutations => {
- for (const m of mutations) {
- this._setAttr(m.attributeName!)
- }
- })
-
- this._ob.observe(this, { attributes: true })
+ this._observe()
const resolve = (def: InnerComponentDef, isAsync = false) => {
this._resolved = true
@@ -430,11 +454,14 @@ export class VueElement
if (!hasOwn(this, key)) {
// exposed properties are readonly
Object.defineProperty(this, key, {
+ configurable: true, // should be configurable to allow deleting when disconnected
// unwrap ref to be consistent with public instance behavior
get: () => unref(exposed[key]),
})
- } else if (__DEV__) {
- warn(`Exposed property "${key}" already exists on custom element.`)
+ } else {
+ delete exposed[key] // delete it from exposed in case of deleting wrong exposed key when disconnected
+ if (__DEV__)
+ warn(`Exposed property "${key}" already exists on custom element.`)
}
}
}
@@ -514,7 +541,7 @@ export class VueElement
} else if (!val) {
this.removeAttribute(hyphenate(key))
}
- ob && ob.observe(this, { attributes: true })
+ this._observe()
}
}
}
diff --git a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
index c875f1bee69..62526beb20f 100644
--- a/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
+++ b/packages/vue/__tests__/e2e/ssr-custom-element.spec.ts
@@ -100,7 +100,6 @@ test('work with Teleport (shadowRoot: false)', async () => {
},
{ shadowRoot: false },
)
- customElements.define('my-y', Y)
const P = defineSSRCustomElement(
{
render() {
@@ -110,6 +109,7 @@ test('work with Teleport (shadowRoot: false)', async () => {
{ shadowRoot: false },
)
customElements.define('my-p', P)
+ customElements.define('my-y', Y)
})
function getInnerHTML() {