Skip to content

Commit c13e674

Browse files
authored
fix(custom-element): batch custom element prop patching (#13478)
close #12619
1 parent 1df8990 commit c13e674

File tree

4 files changed

+237
-12
lines changed

4 files changed

+237
-12
lines changed

packages/runtime-core/src/component.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,14 @@ export interface ComponentCustomElementInterface {
12701270
shouldReflect?: boolean,
12711271
shouldUpdate?: boolean,
12721272
): void
1273+
/**
1274+
* @internal
1275+
*/
1276+
_beginPatch(): void
1277+
/**
1278+
* @internal
1279+
*/
1280+
_endPatch(): void
12731281
/**
12741282
* @internal attached by the nested Teleport when shadowRoot is false.
12751283
*/

packages/runtime-core/src/renderer.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -621,15 +621,27 @@ function baseCreateRenderer(
621621
optimized,
622622
)
623623
} else {
624-
patchElement(
625-
n1,
626-
n2,
627-
parentComponent,
628-
parentSuspense,
629-
namespace,
630-
slotScopeIds,
631-
optimized,
632-
)
624+
const customElement = !!(n1.el && (n1.el as VueElement)._isVueCE)
625+
? (n1.el as VueElement)
626+
: null
627+
try {
628+
if (customElement) {
629+
customElement._beginPatch()
630+
}
631+
patchElement(
632+
n1,
633+
n2,
634+
parentComponent,
635+
parentSuspense,
636+
namespace,
637+
slotScopeIds,
638+
optimized,
639+
)
640+
} finally {
641+
if (customElement) {
642+
customElement._endPatch()
643+
}
644+
}
633645
}
634646
}
635647

packages/runtime-dom/__tests__/customElement.spec.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,190 @@ describe('defineCustomElement', () => {
499499
'<div><span>1 is number</span><span>true is boolean</span></div>',
500500
)
501501
})
502+
503+
test('should patch all props together', async () => {
504+
let prop1Calls = 0
505+
let prop2Calls = 0
506+
const E = defineCustomElement({
507+
props: {
508+
prop1: {
509+
type: String,
510+
default: 'default1',
511+
},
512+
prop2: {
513+
type: String,
514+
default: 'default2',
515+
},
516+
},
517+
data() {
518+
return {
519+
data1: 'defaultData1',
520+
data2: 'defaultData2',
521+
}
522+
},
523+
watch: {
524+
prop1(_) {
525+
prop1Calls++
526+
this.data2 = this.prop2
527+
},
528+
prop2(_) {
529+
prop2Calls++
530+
this.data1 = this.prop1
531+
},
532+
},
533+
render() {
534+
return h('div', [
535+
h('h1', this.prop1),
536+
h('h1', this.prop2),
537+
h('h2', this.data1),
538+
h('h2', this.data2),
539+
])
540+
},
541+
})
542+
customElements.define('my-watch-element', E)
543+
544+
render(h('my-watch-element'), container)
545+
const e = container.childNodes[0] as VueElement
546+
expect(e).toBeInstanceOf(E)
547+
expect(e._instance).toBeTruthy()
548+
expect(e.shadowRoot!.innerHTML).toBe(
549+
`<div><h1>default1</h1><h1>default2</h1><h2>defaultData1</h2><h2>defaultData2</h2></div>`,
550+
)
551+
expect(prop1Calls).toBe(0)
552+
expect(prop2Calls).toBe(0)
553+
554+
// patch props
555+
render(
556+
h('my-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
557+
container,
558+
)
559+
await nextTick()
560+
expect(e.shadowRoot!.innerHTML).toBe(
561+
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
562+
)
563+
expect(prop1Calls).toBe(1)
564+
expect(prop2Calls).toBe(1)
565+
566+
// same prop values
567+
render(
568+
h('my-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
569+
container,
570+
)
571+
await nextTick()
572+
expect(e.shadowRoot!.innerHTML).toBe(
573+
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
574+
)
575+
expect(prop1Calls).toBe(1)
576+
expect(prop2Calls).toBe(1)
577+
578+
// update only prop1
579+
render(
580+
h('my-watch-element', { prop1: 'newValue3', prop2: 'newValue2' }),
581+
container,
582+
)
583+
await nextTick()
584+
expect(e.shadowRoot!.innerHTML).toBe(
585+
`<div><h1>newValue3</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
586+
)
587+
expect(prop1Calls).toBe(2)
588+
expect(prop2Calls).toBe(1)
589+
})
590+
591+
test('should patch all props together (async)', async () => {
592+
let prop1Calls = 0
593+
let prop2Calls = 0
594+
const E = defineCustomElement(
595+
defineAsyncComponent(() =>
596+
Promise.resolve(
597+
defineComponent({
598+
props: {
599+
prop1: {
600+
type: String,
601+
default: 'default1',
602+
},
603+
prop2: {
604+
type: String,
605+
default: 'default2',
606+
},
607+
},
608+
data() {
609+
return {
610+
data1: 'defaultData1',
611+
data2: 'defaultData2',
612+
}
613+
},
614+
watch: {
615+
prop1(_) {
616+
prop1Calls++
617+
this.data2 = this.prop2
618+
},
619+
prop2(_) {
620+
prop2Calls++
621+
this.data1 = this.prop1
622+
},
623+
},
624+
render() {
625+
return h('div', [
626+
h('h1', this.prop1),
627+
h('h1', this.prop2),
628+
h('h2', this.data1),
629+
h('h2', this.data2),
630+
])
631+
},
632+
}),
633+
),
634+
),
635+
)
636+
customElements.define('my-async-watch-element', E)
637+
638+
render(h('my-async-watch-element'), container)
639+
640+
await new Promise(r => setTimeout(r))
641+
const e = container.childNodes[0] as VueElement
642+
expect(e).toBeInstanceOf(E)
643+
expect(e._instance).toBeTruthy()
644+
expect(e.shadowRoot!.innerHTML).toBe(
645+
`<div><h1>default1</h1><h1>default2</h1><h2>defaultData1</h2><h2>defaultData2</h2></div>`,
646+
)
647+
expect(prop1Calls).toBe(0)
648+
expect(prop2Calls).toBe(0)
649+
650+
// patch props
651+
render(
652+
h('my-async-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
653+
container,
654+
)
655+
await nextTick()
656+
expect(e.shadowRoot!.innerHTML).toBe(
657+
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
658+
)
659+
expect(prop1Calls).toBe(1)
660+
expect(prop2Calls).toBe(1)
661+
662+
// same prop values
663+
render(
664+
h('my-async-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
665+
container,
666+
)
667+
await nextTick()
668+
expect(e.shadowRoot!.innerHTML).toBe(
669+
`<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
670+
)
671+
expect(prop1Calls).toBe(1)
672+
expect(prop2Calls).toBe(1)
673+
674+
// update only prop1
675+
render(
676+
h('my-async-watch-element', { prop1: 'newValue3', prop2: 'newValue2' }),
677+
container,
678+
)
679+
await nextTick()
680+
expect(e.shadowRoot!.innerHTML).toBe(
681+
`<div><h1>newValue3</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
682+
)
683+
expect(prop1Calls).toBe(2)
684+
expect(prop2Calls).toBe(1)
685+
})
502686
})
503687

504688
describe('attrs', () => {

packages/runtime-dom/src/apiCustomElement.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ export class VueElement
229229

230230
private _connected = false
231231
private _resolved = false
232+
private _patching = false
233+
private _dirty = false
232234
private _numberProps: Record<string, true> | null = null
233235
private _styleChildren = new WeakSet()
234236
private _pendingResolve: Promise<void> | undefined
@@ -468,11 +470,11 @@ export class VueElement
468470
// defining getter/setters on prototype
469471
for (const key of declaredPropKeys.map(camelize)) {
470472
Object.defineProperty(this, key, {
471-
get() {
473+
get(this: VueElement) {
472474
return this._getProp(key)
473475
},
474-
set(val) {
475-
this._setProp(key, val, true, true)
476+
set(this: VueElement, val) {
477+
this._setProp(key, val, true, !this._patching)
476478
},
477479
})
478480
}
@@ -506,6 +508,7 @@ export class VueElement
506508
shouldUpdate = false,
507509
): void {
508510
if (val !== this._props[key]) {
511+
this._dirty = true
509512
if (val === REMOVAL) {
510513
delete this._props[key]
511514
} else {
@@ -697,6 +700,24 @@ export class VueElement
697700
this._applyStyles(comp.styles, comp)
698701
}
699702

703+
/**
704+
* @internal
705+
*/
706+
_beginPatch(): void {
707+
this._patching = true
708+
this._dirty = false
709+
}
710+
711+
/**
712+
* @internal
713+
*/
714+
_endPatch(): void {
715+
this._patching = false
716+
if (this._dirty && this._instance) {
717+
this._update()
718+
}
719+
}
720+
700721
/**
701722
* @internal
702723
*/

0 commit comments

Comments
 (0)