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() {