From 8ff918af58a210f4e3a231aafedd652a79d52c59 Mon Sep 17 00:00:00 2001 From: OlTrenin Date: Sat, 26 Jul 2025 11:47:27 +0300 Subject: [PATCH 1/3] fix(runtime-dom): allow custom element prop overrides via prototype (#13706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define custom element properties on prototype instead of instance to allow subclasses to override property setters for validation and custom behavior. Fixes #13706 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../__tests__/customElement.spec.ts | 41 +++++++++++++++++++ packages/runtime-dom/src/apiCustomElement.ts | 18 ++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index 07ea091486e..efd41054aa8 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1401,6 +1401,47 @@ 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
') + }) + describe('expose', () => { test('expose w/ options api', async () => { const E = defineCustomElement({ diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index edf7c431353..d1936c6b3d3 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -456,14 +456,16 @@ export class VueElement // defining getter/setters on prototype for (const key of declaredPropKeys.map(camelize)) { - Object.defineProperty(this, key, { - get() { - return this._getProp(key) - }, - set(val) { - this._setProp(key, val, true, true) - }, - }) + if (!Object.prototype.hasOwnProperty.call(this.constructor.prototype, key)) { + Object.defineProperty(this.constructor.prototype, key, { + get() { + return this._getProp(key) + }, + set(val) { + this._setProp(key, val, true, true) + }, + }) + } } } From 07ff2bf14e0f7e014f7a3b0b16ca1f2924d0447a Mon Sep 17 00:00:00 2001 From: OlTrenin Date: Sun, 27 Jul 2025 09:16:29 +0300 Subject: [PATCH 2/3] fix(runtime-dom): refactor and add unit tests --- .../__tests__/customElement.spec.ts | 163 +++++++++++++++++- packages/runtime-dom/src/apiCustomElement.ts | 33 +++- 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index efd41054aa8..c78adf84879 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1442,6 +1442,159 @@ describe('defineCustomElement', () => { expect(e.shadowRoot!.innerHTML).toBe('
another-valid
') }) + test('properties are defined on prototype not instance', () => { + 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 the prototype, not instances + expect(e1.hasOwnProperty('testProp')).toBe(false) + expect(e1.hasOwnProperty('anotherProp')).toBe(false) + expect(Object.hasOwnProperty.call(E.prototype, 'testProp')).toBe(true) + expect(Object.hasOwnProperty.call(E.prototype, 'anotherProp')).toBe(true) + + // Properties should have getter and setter functions + const descriptor = Object.getOwnPropertyDescriptor(E.prototype, '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({ @@ -1535,7 +1688,7 @@ describe('defineCustomElement', () => { const E = defineCustomElement( defineAsyncComponent(() => { return Promise.resolve({ - setup(props) { + setup() { provide('foo', 'foo') }, render(this: any) { @@ -1546,7 +1699,7 @@ describe('defineCustomElement', () => { ) const EChild = defineCustomElement({ - setup(props) { + setup() { fooVal = inject('foo') }, render(this: any) { @@ -1569,7 +1722,7 @@ describe('defineCustomElement', () => { const E = defineCustomElement( defineAsyncComponent(() => { return Promise.resolve({ - setup(props) { + setup() { provide('foo', 'foo') }, render(this: any) { @@ -1580,7 +1733,7 @@ describe('defineCustomElement', () => { ) const EChild = defineCustomElement({ - setup(props) { + setup() { provide('bar', 'bar') }, render(this: any) { @@ -1589,7 +1742,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 d1936c6b3d3..cdc4d52f35c 100644 --- a/packages/runtime-dom/src/apiCustomElement.ts +++ b/packages/runtime-dom/src/apiCustomElement.ts @@ -443,6 +443,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,9 +460,14 @@ export class VueElement } } - // defining getter/setters on prototype + // defining getter/setters on prototype to allow subclass overrides for (const key of declaredPropKeys.map(camelize)) { - if (!Object.prototype.hasOwnProperty.call(this.constructor.prototype, key)) { + // Always define the Vue property on the current prototype, but check if a parent + // class in the prototype chain already has the property defined by a subclass. + // This ensures super.property calls work while allowing subclass overrides. + const hasSubclassOverride = this.constructor.prototype.hasOwnProperty(key) + + if (!hasSubclassOverride) { Object.defineProperty(this.constructor.prototype, key, { get() { return this._getProp(key) @@ -465,6 +476,24 @@ export class VueElement this._setProp(key, val, true, true) }, }) + } else { + 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) + }, + }) + } } } } From 1a205cae4d6c494ac563229ced14d0f4726cf7ed Mon Sep 17 00:00:00 2001 From: OlTrenin Date: Mon, 28 Jul 2025 10:16:58 +0300 Subject: [PATCH 3/3] fix(runtime-dom): refactor for unit tests --- .../__tests__/customElement.spec.ts | 12 +++---- packages/runtime-dom/src/apiCustomElement.ts | 32 ++++++++++--------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts index c78adf84879..d62d9eef762 100644 --- a/packages/runtime-dom/__tests__/customElement.spec.ts +++ b/packages/runtime-dom/__tests__/customElement.spec.ts @@ -1442,7 +1442,7 @@ describe('defineCustomElement', () => { expect(e.shadowRoot!.innerHTML).toBe('
another-valid
') }) - test('properties are defined on prototype not instance', () => { + test('properties are defined on instance for backward compatibility', () => { const E = defineCustomElement({ props: { testProp: String, @@ -1460,14 +1460,12 @@ describe('defineCustomElement', () => { container.appendChild(e1) container.appendChild(e2) - // Properties should be defined on the prototype, not instances - expect(e1.hasOwnProperty('testProp')).toBe(false) - expect(e1.hasOwnProperty('anotherProp')).toBe(false) - expect(Object.hasOwnProperty.call(E.prototype, 'testProp')).toBe(true) - expect(Object.hasOwnProperty.call(E.prototype, 'anotherProp')).toBe(true) + // 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(E.prototype, 'testProp') + const descriptor = Object.getOwnPropertyDescriptor(e1, 'testProp') expect(descriptor).toBeDefined() expect(typeof descriptor!.get).toBe('function') expect(typeof descriptor!.set).toBe('function') diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts index cdc4d52f35c..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 @@ -460,23 +463,12 @@ export class VueElement } } - // defining getter/setters on prototype to allow subclass overrides + // defining getter/setters to support property access for (const key of declaredPropKeys.map(camelize)) { - // Always define the Vue property on the current prototype, but check if a parent - // class in the prototype chain already has the property defined by a subclass. - // This ensures super.property calls work while allowing subclass overrides. + // Check if a subclass has already defined this property const hasSubclassOverride = this.constructor.prototype.hasOwnProperty(key) - if (!hasSubclassOverride) { - Object.defineProperty(this.constructor.prototype, key, { - get() { - return this._getProp(key) - }, - set(val) { - this._setProp(key, val, true, true) - }, - }) - } else { + if (hasSubclassOverride) { const parentPrototype = Object.getPrototypeOf( this.constructor.prototype, ) @@ -494,6 +486,15 @@ export class VueElement }, }) } + } else { + Object.defineProperty(this, key, { + get() { + return this._getProp(key) + }, + set(val) { + this._setProp(key, val, true, true) + }, + }) } } } @@ -506,6 +507,7 @@ export class VueElement if (has && this._numberProps && this._numberProps[camelKey]) { value = toNumber(value) } + this._setProp(camelKey, value, false, true) }