Skip to content

fix(runtime-dom): allow custom element prop overrides via prototype #13707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 199 additions & 5 deletions packages/runtime-dom/__tests__/customElement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@
// @ts-expect-error
e.foo = '123'
container.appendChild(e)
expect(e.shadowRoot!.innerHTML).toBe('<div></div>')

Check failure on line 502 in packages/runtime-dom/__tests__/customElement.spec.ts

View workflow job for this annotation

GitHub Actions / test / unit-test

packages/runtime-dom/__tests__/customElement.spec.ts > defineCustomElement > attrs > non-declared properties should not show up in $attrs

AssertionError: expected '<div>123</div>' to be '<div></div>' // Object.is equality Expected: "<div></div>" Received: "<div>123</div>" ❯ packages/runtime-dom/__tests__/customElement.spec.ts:502:39
})
})

Expand Down Expand Up @@ -1350,7 +1350,7 @@

isShown.value = false
await nextTick()
expect(container.innerHTML).toBe(

Check failure on line 1353 in packages/runtime-dom/__tests__/customElement.spec.ts

View workflow job for this annotation

GitHub Actions / test / unit-test

packages/runtime-dom/__tests__/customElement.spec.ts > defineCustomElement > shadowRoot: false > toggle nested custom element with shadowRoot: false

AssertionError: expected '<my-el-parent-shadow-false is-shown="…' to be '<my-el-parent-shadow-false data-v-app…' // Object.is equality Expected: "<my-el-parent-shadow-false data-v-app=""><!----></my-el-parent-shadow-false>" Received: "<my-el-parent-shadow-false is-shown="" data-v-app=""><div><my-el-child-shadow-false data-v-app=""><div>child</div></my-el-child-shadow-false></div></my-el-parent-shadow-false>" ❯ packages/runtime-dom/__tests__/customElement.spec.ts:1353:35
`<my-el-parent-shadow-false data-v-app=""><!----></my-el-parent-shadow-false>`,
)

Expand Down Expand Up @@ -1401,6 +1401,200 @@
})
})

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('<div>valid-date</div>')

e.value = 'invalid-date'
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>valid-date</div>') // Should remain unchanged

e.value = 'another-valid'
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>another-valid</div>')
})

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('<div>empty</div>')

validating.value = 'valid-test'
await nextTick()
expect(validating.shadowRoot!.innerHTML).toBe('<div>valid-test</div>')

// Test uppercase subclass
uppercase.value = 'hello world'
await nextTick()
expect(uppercase.shadowRoot!.innerHTML).toBe('<div>HELLO WORLD</div>')
})

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('<div>undefined-25-true</div>')

e.name = 'alice' // Valid
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>alice-25-true</div>')

// Test restricted age
e.age = -5 // Invalid
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>alice-25-true</div>')

e.age = 30 // Valid
await nextTick()
expect(e.shadowRoot!.innerHTML).toBe('<div>alice-30-true</div>')
})

describe('expose', () => {
test('expose w/ options api', async () => {
const E = defineCustomElement({
Expand Down Expand Up @@ -1485,7 +1679,7 @@

expect(
`[Vue warn]: Exposed property "value" already exists on custom element.`,
).toHaveBeenWarned()

Check failure on line 1682 in packages/runtime-dom/__tests__/customElement.spec.ts

View workflow job for this annotation

GitHub Actions / test / unit-test

packages/runtime-dom/__tests__/customElement.spec.ts > defineCustomElement > expose > warning when exposing an existing property

Error: expected "[Vue warn]: Exposed property "value" already exists on custom element." to have been warned but no warning was recorded. ❯ packages/runtime-dom/__tests__/customElement.spec.ts:1682:9
})
})

Expand All @@ -1494,7 +1688,7 @@
const E = defineCustomElement(
defineAsyncComponent(() => {
return Promise.resolve({
setup(props) {
setup() {
provide('foo', 'foo')
},
render(this: any) {
Expand All @@ -1505,7 +1699,7 @@
)

const EChild = defineCustomElement({
setup(props) {
setup() {
fooVal = inject('foo')
},
render(this: any) {
Expand All @@ -1528,7 +1722,7 @@
const E = defineCustomElement(
defineAsyncComponent(() => {
return Promise.resolve({
setup(props) {
setup() {
provide('foo', 'foo')
},
render(this: any) {
Expand All @@ -1539,7 +1733,7 @@
)

const EChild = defineCustomElement({
setup(props) {
setup() {
provide('bar', 'bar')
},
render(this: any) {
Expand All @@ -1548,7 +1742,7 @@
})

const EChild2 = defineCustomElement({
setup(props) {
setup() {
fooVal = inject('foo')
barVal = inject('bar')
},
Expand Down
49 changes: 40 additions & 9 deletions packages/runtime-dom/src/apiCustomElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {})
Expand All @@ -454,16 +460,41 @@ export class VueElement
}
}

// defining getter/setters on prototype
// defining getter/setters on prototype to allow subclass overrides
for (const key of declaredPropKeys.map(camelize)) {
Object.defineProperty(this, key, {
get() {
return this._getProp(key)
},
set(val) {
this._setProp(key, val, true, true)
},
})
// 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)
},
set(val) {
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)
},
})
}
}
}
}

Expand Down
Loading