diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index f191c36df12..ea9b43bc324 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -1270,6 +1270,14 @@ export interface ComponentCustomElementInterface { shouldReflect?: boolean, shouldUpdate?: boolean, ): void + /** + * @internal + */ + _beginPatch(): void + /** + * @internal + */ + _endPatch(): void /** * @internal attached by the nested Teleport when shadowRoot is false. */ diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a57be791a44..4e1c91b20d3 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -621,15 +621,27 @@ function baseCreateRenderer( optimized, ) } else { - patchElement( - n1, - n2, - parentComponent, - parentSuspense, - namespace, - slotScopeIds, - optimized, - ) + const customElement = !!(n1.el && (n1.el as VueElement)._isVueCE) + ? (n1.el as VueElement) + : null + try { + if (customElement) { + customElement._beginPatch() + } + patchElement( + n1, + n2, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized, + ) + } finally { + if (customElement) { + customElement._endPatch() + } + } } } diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index c44840df5e3..fe7aef1d015 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -474,6 +474,190 @@ describe('defineCustomElement', () => { '
1 is numbertrue is boolean
', ) }) + + test('should patch all props together', async () => { + let prop1Calls = 0 + let prop2Calls = 0 + const E = defineCustomElement({ + props: { + prop1: { + type: String, + default: 'default1', + }, + prop2: { + type: String, + default: 'default2', + }, + }, + data() { + return { + data1: 'defaultData1', + data2: 'defaultData2', + } + }, + watch: { + prop1(_) { + prop1Calls++ + this.data2 = this.prop2 + }, + prop2(_) { + prop2Calls++ + this.data1 = this.prop1 + }, + }, + render() { + return h('div', [ + h('h1', this.prop1), + h('h1', this.prop2), + h('h2', this.data1), + h('h2', this.data2), + ]) + }, + }) + customElements.define('my-watch-element', E) + + render(h('my-watch-element'), container) + const e = container.childNodes[0] as VueElement + expect(e).toBeInstanceOf(E) + expect(e._instance).toBeTruthy() + expect(e.shadowRoot!.innerHTML).toBe( + `

default1

default2

defaultData1

defaultData2

`, + ) + expect(prop1Calls).toBe(0) + expect(prop2Calls).toBe(0) + + // patch props + render( + h('my-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }), + container, + ) + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `

newValue1

newValue2

newValue1

newValue2

`, + ) + expect(prop1Calls).toBe(1) + expect(prop2Calls).toBe(1) + + // same prop values + render( + h('my-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }), + container, + ) + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `

newValue1

newValue2

newValue1

newValue2

`, + ) + expect(prop1Calls).toBe(1) + expect(prop2Calls).toBe(1) + + // update only prop1 + render( + h('my-watch-element', { prop1: 'newValue3', prop2: 'newValue2' }), + container, + ) + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `

newValue3

newValue2

newValue1

newValue2

`, + ) + expect(prop1Calls).toBe(2) + expect(prop2Calls).toBe(1) + }) + + test('should patch all props together (async)', async () => { + let prop1Calls = 0 + let prop2Calls = 0 + const E = defineCustomElement( + defineAsyncComponent(() => + Promise.resolve( + defineComponent({ + props: { + prop1: { + type: String, + default: 'default1', + }, + prop2: { + type: String, + default: 'default2', + }, + }, + data() { + return { + data1: 'defaultData1', + data2: 'defaultData2', + } + }, + watch: { + prop1(_) { + prop1Calls++ + this.data2 = this.prop2 + }, + prop2(_) { + prop2Calls++ + this.data1 = this.prop1 + }, + }, + render() { + return h('div', [ + h('h1', this.prop1), + h('h1', this.prop2), + h('h2', this.data1), + h('h2', this.data2), + ]) + }, + }), + ), + ), + ) + customElements.define('my-async-watch-element', E) + + render(h('my-async-watch-element'), container) + + await new Promise(r => setTimeout(r)) + const e = container.childNodes[0] as VueElement + expect(e).toBeInstanceOf(E) + expect(e._instance).toBeTruthy() + expect(e.shadowRoot!.innerHTML).toBe( + `

default1

default2

defaultData1

defaultData2

`, + ) + expect(prop1Calls).toBe(0) + expect(prop2Calls).toBe(0) + + // patch props + render( + h('my-async-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }), + container, + ) + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `

newValue1

newValue2

newValue1

newValue2

`, + ) + expect(prop1Calls).toBe(1) + expect(prop2Calls).toBe(1) + + // same prop values + render( + h('my-async-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }), + container, + ) + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `

newValue1

newValue2

newValue1

newValue2

`, + ) + expect(prop1Calls).toBe(1) + expect(prop2Calls).toBe(1) + + // update only prop1 + render( + h('my-async-watch-element', { prop1: 'newValue3', prop2: 'newValue2' }), + container, + ) + await nextTick() + expect(e.shadowRoot!.innerHTML).toBe( + `

newValue3

newValue2

newValue1

newValue2

`, + ) + expect(prop1Calls).toBe(2) + expect(prop2Calls).toBe(1) + }) }) describe('attrs', () => { diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index edf7c431353..9cddccb4e46 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -228,6 +228,8 @@ export class VueElement private _connected = false private _resolved = false + private _patching = false + private _dirty = false private _numberProps: Record | null = null private _styleChildren = new WeakSet() private _pendingResolve: Promise | undefined @@ -457,11 +459,11 @@ export class VueElement // defining getter/setters on prototype for (const key of declaredPropKeys.map(camelize)) { Object.defineProperty(this, key, { - get() { + get(this: VueElement) { return this._getProp(key) }, - set(val) { - this._setProp(key, val, true, true) + set(this: VueElement, val) { + this._setProp(key, val, true, !this._patching) }, }) } @@ -495,6 +497,7 @@ export class VueElement shouldUpdate = false, ): void { if (val !== this._props[key]) { + this._dirty = true if (val === REMOVAL) { delete this._props[key] } else { @@ -670,6 +673,24 @@ export class VueElement this._applyStyles(comp.styles, comp) } + /** + * @internal + */ + _beginPatch(): void { + this._patching = true + this._dirty = false + } + + /** + * @internal + */ + _endPatch(): void { + this._patching = false + if (this._dirty && this._instance) { + this._update() + } + } + /** * @internal */