diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts
index 07ea091486e..d62d9eef762 100644
--- a/packages/runtime-dom/__tests__/customElement.spec.ts
+++ b/packages/runtime-dom/__tests__/customElement.spec.ts
@@ -1401,6 +1401,198 @@ describe('defineCustomElement', () => {
})
})
+ test('subclasses can override property setters', async () => {
+ const E = defineCustomElement({
+ props: {
+ value: String,
+ },
+ render() {
+ return h('div', this.value)
+ },
+ })
+
+ class SubclassedElement extends E {
+ set value(val: string) {
+ if (val && val !== 'valid-date' && val.includes('invalid')) {
+ return
+ }
+ super.value = val
+ }
+
+ get value(): string {
+ return super.value || ''
+ }
+ }
+
+ customElements.define('my-subclassed-element', SubclassedElement)
+
+ const e = new SubclassedElement()
+ container.appendChild(e)
+
+ e.value = 'valid-date'
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('
valid-date
')
+
+ e.value = 'invalid-date'
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('valid-date
') // Should remain unchanged
+
+ e.value = 'another-valid'
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('another-valid
')
+ })
+
+ test('properties are defined on instance for backward compatibility', () => {
+ const E = defineCustomElement({
+ props: {
+ testProp: String,
+ anotherProp: Number,
+ },
+ render() {
+ return h('div', `${this.testProp}-${this.anotherProp}`)
+ },
+ })
+
+ customElements.define('my-prototype-test', E)
+
+ const e1 = new E()
+ const e2 = new E()
+ container.appendChild(e1)
+ container.appendChild(e2)
+
+ // Properties should be defined on instances for backward compatibility
+ expect(e1.hasOwnProperty('testProp')).toBe(true)
+ expect(e1.hasOwnProperty('anotherProp')).toBe(true)
+
+ // Properties should have getter and setter functions
+ const descriptor = Object.getOwnPropertyDescriptor(e1, 'testProp')
+ expect(descriptor).toBeDefined()
+ expect(typeof descriptor!.get).toBe('function')
+ expect(typeof descriptor!.set).toBe('function')
+ })
+
+ test('multiple subclasses with different override behaviors', async () => {
+ const E = defineCustomElement({
+ props: {
+ value: String,
+ },
+ render() {
+ return h('div', this.value || 'empty')
+ },
+ })
+
+ class ValidatingSubclass extends E {
+ set value(val: string) {
+ // Only allow values that start with 'valid-'
+ if (val && val.startsWith('valid-')) {
+ super.value = val
+ }
+ }
+
+ get value(): string {
+ return super.value || ''
+ }
+ }
+
+ class UppercaseSubclass extends E {
+ set value(val: string) {
+ // Convert to uppercase
+ super.value = val ? val.toUpperCase() : val
+ }
+
+ get value(): string {
+ return super.value || ''
+ }
+ }
+
+ customElements.define('validating-element', ValidatingSubclass)
+ customElements.define('uppercase-element', UppercaseSubclass)
+
+ const validating = new ValidatingSubclass()
+ const uppercase = new UppercaseSubclass()
+ container.appendChild(validating)
+ container.appendChild(uppercase)
+
+ // Test validating subclass
+ validating.value = 'invalid-test'
+ await nextTick()
+ expect(validating.shadowRoot!.innerHTML).toBe('empty
')
+
+ validating.value = 'valid-test'
+ await nextTick()
+ expect(validating.shadowRoot!.innerHTML).toBe('valid-test
')
+
+ // Test uppercase subclass
+ uppercase.value = 'hello world'
+ await nextTick()
+ expect(uppercase.shadowRoot!.innerHTML).toBe('HELLO WORLD
')
+ })
+
+ test('subclass override with multiple props', async () => {
+ const E = defineCustomElement({
+ props: {
+ name: String,
+ age: Number,
+ active: Boolean,
+ },
+ render() {
+ return h('div', `${this.name}-${this.age}-${this.active}`)
+ },
+ })
+
+ class RestrictedSubclass extends E {
+ set name(val: string) {
+ // Only allow names with at least 3 characters
+ if (val && val.length >= 3) {
+ super.name = val
+ }
+ }
+
+ get name(): string {
+ const value = super.name
+ return value != null ? value : 'default'
+ }
+
+ set age(val: number) {
+ // Only allow positive ages
+ if (val && val > 0) {
+ super.age = val
+ }
+ }
+
+ get age(): number {
+ const value = super.age
+ return value != null ? value : 0
+ }
+ }
+
+ customElements.define('restricted-element', RestrictedSubclass)
+
+ const e = new RestrictedSubclass()
+ container.appendChild(e)
+
+ // Test restricted name
+ e.name = 'ab' // Too short, should be rejected
+ e.age = 25
+ e.active = true
+ await nextTick()
+ // Since the short name was rejected, Vue property remains undefined
+ expect(e.shadowRoot!.innerHTML).toBe('undefined-25-true
')
+
+ e.name = 'alice' // Valid
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('alice-25-true
')
+
+ // Test restricted age
+ e.age = -5 // Invalid
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('alice-25-true
')
+
+ e.age = 30 // Valid
+ await nextTick()
+ expect(e.shadowRoot!.innerHTML).toBe('alice-30-true
')
+ })
+
describe('expose', () => {
test('expose w/ options api', async () => {
const E = defineCustomElement({
@@ -1494,7 +1686,7 @@ describe('defineCustomElement', () => {
const E = defineCustomElement(
defineAsyncComponent(() => {
return Promise.resolve({
- setup(props) {
+ setup() {
provide('foo', 'foo')
},
render(this: any) {
@@ -1505,7 +1697,7 @@ describe('defineCustomElement', () => {
)
const EChild = defineCustomElement({
- setup(props) {
+ setup() {
fooVal = inject('foo')
},
render(this: any) {
@@ -1528,7 +1720,7 @@ describe('defineCustomElement', () => {
const E = defineCustomElement(
defineAsyncComponent(() => {
return Promise.resolve({
- setup(props) {
+ setup() {
provide('foo', 'foo')
},
render(this: any) {
@@ -1539,7 +1731,7 @@ describe('defineCustomElement', () => {
)
const EChild = defineCustomElement({
- setup(props) {
+ setup() {
provide('bar', 'bar')
},
render(this: any) {
@@ -1548,7 +1740,7 @@ describe('defineCustomElement', () => {
})
const EChild2 = defineCustomElement({
- setup(props) {
+ setup() {
fooVal = inject('foo')
barVal = inject('bar')
},
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
index edf7c431353..4f7c380f57b 100644
--- a/packages/runtime-dom/src/apiCustomElement.ts
+++ b/packages/runtime-dom/src/apiCustomElement.ts
@@ -431,7 +431,10 @@ export class VueElement
const exposed = this._instance && this._instance.exposed
if (!exposed) return
for (const key in exposed) {
- if (!hasOwn(this, key)) {
+ const hasInstanceProperty = hasOwn(this, key)
+ const hasOwnPrototypeProperty = hasOwn(this.constructor.prototype, key)
+
+ if (!hasInstanceProperty && !hasOwnPrototypeProperty) {
// exposed properties are readonly
Object.defineProperty(this, key, {
// unwrap ref to be consistent with public instance behavior
@@ -443,6 +446,12 @@ export class VueElement
}
}
+ /**
+ * Resolves component props by setting up property getters/setters on the prototype.
+ * This allows subclasses to override property setters for validation and custom behavior.
+ * @param def - The inner component definition containing props configuration
+ * @internal
+ */
private _resolveProps(def: InnerComponentDef) {
const { props } = def
const declaredPropKeys = isArray(props) ? props : Object.keys(props || {})
@@ -454,16 +463,39 @@ export class VueElement
}
}
- // defining getter/setters on prototype
+ // defining getter/setters to support property access
for (const key of declaredPropKeys.map(camelize)) {
- Object.defineProperty(this, key, {
- get() {
- return this._getProp(key)
- },
- set(val) {
- this._setProp(key, val, true, true)
- },
- })
+ // Check if a subclass has already defined this property
+ const hasSubclassOverride = this.constructor.prototype.hasOwnProperty(key)
+
+ if (hasSubclassOverride) {
+ const parentPrototype = Object.getPrototypeOf(
+ this.constructor.prototype,
+ )
+ if (
+ parentPrototype &&
+ parentPrototype !== Object.prototype &&
+ !Object.prototype.hasOwnProperty.call(parentPrototype, key)
+ ) {
+ Object.defineProperty(parentPrototype, key, {
+ get() {
+ return this._getProp(key)
+ },
+ set(val) {
+ this._setProp(key, val, true, true)
+ },
+ })
+ }
+ } else {
+ Object.defineProperty(this, key, {
+ get() {
+ return this._getProp(key)
+ },
+ set(val) {
+ this._setProp(key, val, true, true)
+ },
+ })
+ }
}
}
@@ -475,6 +507,7 @@ export class VueElement
if (has && this._numberProps && this._numberProps[camelKey]) {
value = toNumber(value)
}
+
this._setProp(camelKey, value, false, true)
}