From d54f6c8063f1e450981a8741226e6a8c4fed5401 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sat, 14 Jun 2025 22:36:31 -0700 Subject: [PATCH 1/2] fix(custom-element): batch custom element prop patching --- packages/runtime-core/src/component.ts | 8 + packages/runtime-core/src/renderer.ts | 27 ++- .../__tests__/customElement.spec.ts | 184 ++++++++++++++++++ packages/runtime-dom/src/apiCustomElement.ts | 27 ++- 4 files changed, 234 insertions(+), 12 deletions(-) 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..a4b058eff07 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -621,15 +621,24 @@ function baseCreateRenderer( optimized, ) } else { - patchElement( - n1, - n2, - parentComponent, - parentSuspense, - namespace, - slotScopeIds, - optimized, - ) + if (n1.el && (n1.el as VueElement)._isVueCE) { + ;(n1.el as VueElement)._beginPatch() + } + try { + patchElement( + n1, + n2, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized, + ) + } finally { + if (n1.el && (n1.el as VueElement)._isVueCE) { + ;(n1.el as VueElement)._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 */ From 1354a625edd22750634dba924e00690483300424 Mon Sep 17 00:00:00 2001 From: Alex Snezhko Date: Sun, 15 Jun 2025 19:51:54 -0700 Subject: [PATCH 2/2] fix: review feedback --- packages/runtime-core/src/renderer.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a4b058eff07..4e1c91b20d3 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -621,10 +621,13 @@ function baseCreateRenderer( optimized, ) } else { - if (n1.el && (n1.el as VueElement)._isVueCE) { - ;(n1.el as VueElement)._beginPatch() - } + const customElement = !!(n1.el && (n1.el as VueElement)._isVueCE) + ? (n1.el as VueElement) + : null try { + if (customElement) { + customElement._beginPatch() + } patchElement( n1, n2, @@ -635,8 +638,8 @@ function baseCreateRenderer( optimized, ) } finally { - if (n1.el && (n1.el as VueElement)._isVueCE) { - ;(n1.el as VueElement)._endPatch() + if (customElement) { + customElement._endPatch() } } }