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) }