-
Notifications
You must be signed in to change notification settings - Fork 512
fix: migrate DOM widgets to ComponentWidgetImpl and add element value bridge #9230
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
base: main
Are you sure you want to change the base?
Changes from all commits
e3fa4e6
2d61d11
222e515
50bd1ee
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| import { effectScope, watch } from 'vue' | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| import { useDomValueBridge } from './useDomValueBridge' | ||
|
|
||
| describe('useDomValueBridge', () => { | ||
| let element: HTMLTextAreaElement | ||
|
|
||
| beforeEach(() => { | ||
| element = document.createElement('textarea') | ||
| element.value = 'initial' | ||
| }) | ||
|
|
||
| it('reads initial element value', () => { | ||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(element) | ||
| expect(ref.value).toBe('initial') | ||
| }) | ||
| scope.stop() | ||
| }) | ||
|
|
||
| it('detects programmatic element.value writes', () => { | ||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(element) | ||
| const spy = vi.fn() | ||
| watch(ref, spy, { flush: 'sync' }) | ||
|
|
||
| element.value = 'programmatic' | ||
|
|
||
| expect(ref.value).toBe('programmatic') | ||
| expect(spy).toHaveBeenCalledWith( | ||
| 'programmatic', | ||
| 'initial', | ||
| expect.anything() | ||
| ) | ||
| }) | ||
| scope.stop() | ||
| }) | ||
|
|
||
| it('detects user input events', () => { | ||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(element) | ||
| const spy = vi.fn() | ||
| watch(ref, spy, { flush: 'sync' }) | ||
|
|
||
| const nativeDesc = Object.getOwnPropertyDescriptor( | ||
| HTMLTextAreaElement.prototype, | ||
| 'value' | ||
| )! | ||
| nativeDesc.set!.call(element, 'typed') | ||
| element.dispatchEvent(new Event('input')) | ||
|
|
||
| expect(ref.value).toBe('typed') | ||
| expect(spy).toHaveBeenCalled() | ||
| }) | ||
| scope.stop() | ||
| }) | ||
|
|
||
| it('setting ref updates element value', () => { | ||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(element) | ||
| ref.value = 'from-ref' | ||
| expect(element.value).toBe('from-ref') | ||
| }) | ||
| scope.stop() | ||
| }) | ||
|
|
||
| it('chains through existing Object.defineProperty on element', () => { | ||
| const existingSetter = vi.fn() | ||
|
|
||
| const nativeDesc = Object.getOwnPropertyDescriptor( | ||
| HTMLTextAreaElement.prototype, | ||
| 'value' | ||
| )! | ||
| Object.defineProperty(element, 'value', { | ||
| configurable: true, | ||
| get() { | ||
| return nativeDesc.get!.call(element) | ||
| }, | ||
| set(v: string) { | ||
| existingSetter(v) | ||
| nativeDesc.set!.call(element, v) | ||
| } | ||
| }) | ||
|
|
||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(element) | ||
|
|
||
| element.value = 'new' | ||
| expect(existingSetter).toHaveBeenCalledWith('new') | ||
| expect(ref.value).toBe('new') | ||
| }) | ||
| scope.stop() | ||
| }) | ||
|
|
||
| it('restores previous descriptor on scope dispose', () => { | ||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| useDomValueBridge(element) | ||
| }) | ||
|
|
||
| const duringDesc = Object.getOwnPropertyDescriptor(element, 'value') | ||
| expect(duringDesc).toBeDefined() | ||
|
|
||
| scope.stop() | ||
|
|
||
| const afterDesc = Object.getOwnPropertyDescriptor(element, 'value') | ||
| expect(afterDesc).toBeUndefined() | ||
| }) | ||
|
|
||
| it('restores existing override descriptor on scope dispose', () => { | ||
| const nativeDesc = Object.getOwnPropertyDescriptor( | ||
| HTMLTextAreaElement.prototype, | ||
| 'value' | ||
| )! | ||
| const customGetter = vi.fn(() => nativeDesc.get!.call(element)) | ||
|
|
||
| Object.defineProperty(element, 'value', { | ||
| configurable: true, | ||
| get: customGetter, | ||
| set(v: string) { | ||
| nativeDesc.set!.call(element, v) | ||
| } | ||
| }) | ||
|
|
||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| useDomValueBridge(element) | ||
| }) | ||
| scope.stop() | ||
|
|
||
| element.value | ||
| expect(customGetter).toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('handles non-configurable descriptor without throwing', () => { | ||
| const nativeDesc = Object.getOwnPropertyDescriptor( | ||
| HTMLTextAreaElement.prototype, | ||
| 'value' | ||
| )! | ||
| Object.defineProperty(element, 'value', { | ||
| configurable: false, | ||
| get() { | ||
| return nativeDesc.get!.call(element) | ||
| }, | ||
| set(v: string) { | ||
| nativeDesc.set!.call(element, v) | ||
| } | ||
| }) | ||
|
|
||
| const scope = effectScope() | ||
| expect(() => { | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(element) | ||
| expect(ref.value).toBe('initial') | ||
| }) | ||
| }).not.toThrow() | ||
| scope.stop() | ||
|
|
||
| const afterDesc = Object.getOwnPropertyDescriptor(element, 'value') | ||
| expect(afterDesc).toBeDefined() | ||
| expect(afterDesc!.configurable).toBe(false) | ||
| }) | ||
|
|
||
| it('works with HTMLInputElement', () => { | ||
| const input = document.createElement('input') | ||
| input.value = 'input-initial' | ||
|
|
||
| const scope = effectScope() | ||
| scope.run(() => { | ||
| const ref = useDomValueBridge(input) | ||
| expect(ref.value).toBe('input-initial') | ||
|
|
||
| input.value = 'input-updated' | ||
| expect(ref.value).toBe('input-updated') | ||
| }) | ||
| scope.stop() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import type { Ref } from 'vue' | ||
| import { customRef, onScopeDispose } from 'vue' | ||
|
|
||
| import { useEventListener } from '@vueuse/core' | ||
|
|
||
| type ValueElement = HTMLInputElement | HTMLTextAreaElement | ||
|
|
||
| export function useDomValueBridge(element: ValueElement): Ref<string> { | ||
| const proto = Object.getPrototypeOf(element) | ||
| const nativeDescriptor = Object.getOwnPropertyDescriptor(proto, 'value') | ||
| const existingDescriptor = Object.getOwnPropertyDescriptor(element, 'value') | ||
|
|
||
| const prevGet = existingDescriptor?.get ?? nativeDescriptor?.get | ||
| const prevSet = existingDescriptor?.set ?? nativeDescriptor?.set | ||
|
|
||
| if (!prevGet || !prevSet) { | ||
| return customRef((track, trigger) => ({ | ||
| get() { | ||
| track() | ||
| return element.value | ||
| }, | ||
| set(v: string) { | ||
| element.value = v | ||
| trigger() | ||
| } | ||
| })) | ||
| } | ||
|
|
||
| let notifyChange: (() => void) | undefined | ||
|
|
||
| const ref = customRef<string>((track, trigger) => { | ||
| notifyChange = trigger | ||
| return { | ||
| get() { | ||
| track() | ||
| return prevGet.call(element) | ||
| }, | ||
| set(v: string) { | ||
| prevSet.call(element, v) | ||
| trigger() | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| const canOverride = | ||
| existingDescriptor == null || existingDescriptor.configurable !== false | ||
|
|
||
| if (canOverride) { | ||
| Object.defineProperty(element, 'value', { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return prevGet.call(element) | ||
| }, | ||
| set(v: string) { | ||
| prevSet.call(element, v) | ||
| notifyChange?.() | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| useEventListener(element, 'input', () => { | ||
| notifyChange?.() | ||
| }) | ||
|
|
||
| onScopeDispose(() => { | ||
| if (!canOverride) return | ||
|
|
||
| if (existingDescriptor) { | ||
| Object.defineProperty(element, 'value', existingDescriptor) | ||
| } else { | ||
| delete (element as unknown as Record<string, unknown>).value | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👀
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah the cast is unfortunate but necessary — |
||
| } | ||
| }) | ||
|
|
||
| return ref | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should come up with a clean API for extension authors so we can add a proper deprecation warning here. How to wire up your custom widget to the widgetValueStore properly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, that deserves its own effort. This PR keeps the existing escape hatch working but a proper public API for extension widget authors would be the right long-term fix.
@coderabbitai Make an Issue to design a clean public API for extension authors to wire custom widgets to the widgetValueStore, replacing the current value-descriptor pattern with a supported integration point.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
@christian-byrneI'll create that issue for you.🏁 Script executed:
Length of output: 57
@christian-byrneIssue created!I've opened
#9296to track the design of a clean public API for extension authors to integrate custom widgets with thewidgetValueStore. The issue captures the current value-descriptor pattern as a temporary workaround and outlines the need for a proper supported integration point with documentation and migration guidance.🧠 Learnings used