fix(custom-element): batch custom element prop patching (#13478)

close #12619
This commit is contained in:
Alex Snezhko 2025-11-05 00:50:00 -08:00 committed by GitHub
parent 1df8990504
commit c13e674fb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 237 additions and 12 deletions

View File

@ -1270,6 +1270,14 @@ export interface ComponentCustomElementInterface {
shouldReflect?: boolean, shouldReflect?: boolean,
shouldUpdate?: boolean, shouldUpdate?: boolean,
): void ): void
/**
* @internal
*/
_beginPatch(): void
/**
* @internal
*/
_endPatch(): void
/** /**
* @internal attached by the nested Teleport when shadowRoot is false. * @internal attached by the nested Teleport when shadowRoot is false.
*/ */

View File

@ -621,15 +621,27 @@ function baseCreateRenderer(
optimized, optimized,
) )
} else { } else {
patchElement( const customElement = !!(n1.el && (n1.el as VueElement)._isVueCE)
n1, ? (n1.el as VueElement)
n2, : null
parentComponent, try {
parentSuspense, if (customElement) {
namespace, customElement._beginPatch()
slotScopeIds, }
optimized, patchElement(
) n1,
n2,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} finally {
if (customElement) {
customElement._endPatch()
}
}
} }
} }

View File

@ -499,6 +499,190 @@ describe('defineCustomElement', () => {
'<div><span>1 is number</span><span>true is boolean</span></div>', '<div><span>1 is number</span><span>true is boolean</span></div>',
) )
}) })
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(
`<div><h1>default1</h1><h1>default2</h1><h2>defaultData1</h2><h2>defaultData2</h2></div>`,
)
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(
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
)
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(
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
)
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(
`<div><h1>newValue3</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
)
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(
`<div><h1>default1</h1><h1>default2</h1><h2>defaultData1</h2><h2>defaultData2</h2></div>`,
)
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(
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
)
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(
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
)
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(
`<div><h1>newValue3</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
)
expect(prop1Calls).toBe(2)
expect(prop2Calls).toBe(1)
})
}) })
describe('attrs', () => { describe('attrs', () => {

View File

@ -229,6 +229,8 @@ export class VueElement
private _connected = false private _connected = false
private _resolved = false private _resolved = false
private _patching = false
private _dirty = false
private _numberProps: Record<string, true> | null = null private _numberProps: Record<string, true> | null = null
private _styleChildren = new WeakSet() private _styleChildren = new WeakSet()
private _pendingResolve: Promise<void> | undefined private _pendingResolve: Promise<void> | undefined
@ -468,11 +470,11 @@ export class VueElement
// defining getter/setters on prototype // defining getter/setters on prototype
for (const key of declaredPropKeys.map(camelize)) { for (const key of declaredPropKeys.map(camelize)) {
Object.defineProperty(this, key, { Object.defineProperty(this, key, {
get() { get(this: VueElement) {
return this._getProp(key) return this._getProp(key)
}, },
set(val) { set(this: VueElement, val) {
this._setProp(key, val, true, true) this._setProp(key, val, true, !this._patching)
}, },
}) })
} }
@ -506,6 +508,7 @@ export class VueElement
shouldUpdate = false, shouldUpdate = false,
): void { ): void {
if (val !== this._props[key]) { if (val !== this._props[key]) {
this._dirty = true
if (val === REMOVAL) { if (val === REMOVAL) {
delete this._props[key] delete this._props[key]
} else { } else {
@ -697,6 +700,24 @@ export class VueElement
this._applyStyles(comp.styles, comp) 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 * @internal
*/ */