Skip to content

Commit d54f6c8

Browse files
committed
fix(custom-element): batch custom element prop patching
1 parent c91afec commit d54f6c8

File tree

4 files changed

+234
-12
lines changed

4 files changed

+234
-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: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -621,15 +621,24 @@ 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+
if (n1.el && (n1.el as VueElement)._isVueCE) {
625+
;(n1.el as VueElement)._beginPatch()
626+
}
627+
try {
628+
patchElement(
629+
n1,
630+
n2,
631+
parentComponent,
632+
parentSuspense,
633+
namespace,
634+
slotScopeIds,
635+
optimized,
636+
)
637+
} finally {
638+
if (n1.el && (n1.el as VueElement)._isVueCE) {
639+
;(n1.el as VueElement)._endPatch()
640+
}
641+
}
633642
}
634643
}
635644

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

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

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

packages/runtime-dom/src/apiCustomElement.ts

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

229229
private _connected = false
230230
private _resolved = false
231+
private _patching = false
232+
private _dirty = false
231233
private _numberProps: Record<string, true> | null = null
232234
private _styleChildren = new WeakSet()
233235
private _pendingResolve: Promise<void> | undefined
@@ -457,11 +459,11 @@ export class VueElement
457459
// defining getter/setters on prototype
458460
for (const key of declaredPropKeys.map(camelize)) {
459461
Object.defineProperty(this, key, {
460-
get() {
462+
get(this: VueElement) {
461463
return this._getProp(key)
462464
},
463-
set(val) {
464-
this._setProp(key, val, true, true)
465+
set(this: VueElement, val) {
466+
this._setProp(key, val, true, !this._patching)
465467
},
466468
})
467469
}
@@ -495,6 +497,7 @@ export class VueElement
495497
shouldUpdate = false,
496498
): void {
497499
if (val !== this._props[key]) {
500+
this._dirty = true
498501
if (val === REMOVAL) {
499502
delete this._props[key]
500503
} else {
@@ -670,6 +673,24 @@ export class VueElement
670673
this._applyStyles(comp.styles, comp)
671674
}
672675

676+
/**
677+
* @internal
678+
*/
679+
_beginPatch(): void {
680+
this._patching = true
681+
this._dirty = false
682+
}
683+
684+
/**
685+
* @internal
686+
*/
687+
_endPatch(): void {
688+
this._patching = false
689+
if (this._dirty && this._instance) {
690+
this._update()
691+
}
692+
}
693+
673694
/**
674695
* @internal
675696
*/

0 commit comments

Comments
 (0)