From 916aa33141aa426d186abb368e7c26de0c3dfee7 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Thu, 10 Sep 2026 21:07:21 +0100 Subject: [PATCH] fix: treat programmatic null input values as empty string --- src/document/UI.ts | 4 ++-- src/document/interceptor.ts | 15 +++++++++------ tests/document/index.ts | 24 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/document/UI.ts b/src/document/UI.ts index 9c509508..78a38c47 100644 --- a/src/document/UI.ts +++ b/src/document/UI.ts @@ -19,9 +19,9 @@ export type UIValueString = string & {[UIValue]: true} export type UISelectionStart = number & {[UISelection]: true} export function isUIValue( - value: string | UIValueString, + value: string | UIValueString | null, ): value is UIValueString { - return typeof value === 'object' && UIValue in value + return !!value && typeof value === 'object' && UIValue in value } export function isUISelectionStart( diff --git a/src/document/interceptor.ts b/src/document/interceptor.ts index ec1abd3d..b4d787fd 100644 --- a/src/document/interceptor.ts +++ b/src/document/interceptor.ts @@ -72,8 +72,9 @@ export function prepareInterceptor< then, } = interceptorImpl.call(this, ...args) - const realFunc = ((!applyNative && objectDescriptor) || - (prototypeDescriptor as PropertyDescriptor))[target] as ( + const sourceDescriptor = + !applyNative && objectDescriptor ? objectDescriptor : prototypeDescriptor + const realFunc = sourceDescriptor?.[target] as ( this: ElementType, ...a: unknown[] ) => unknown @@ -102,9 +103,11 @@ export function prepareValueInterceptor( 'value', function interceptorImpl( this: HTMLInputElement | HTMLTextAreaElement, - v: UIValueString | string, + v: UIValueString | string | null, ) { - const isUI = isUIValue(v) + // `value` is a [LegacyNullToEmptyString] DOMString. + const value = v === null ? '' : v + const isUI = isUIValue(value) if (isUI) { startTrackValue(this) @@ -112,8 +115,8 @@ export function prepareValueInterceptor( return { applyNative: !!isUI, - realArgs: sanitizeValue(this, v), - then: isUI ? undefined : () => trackOrSetValue(this, String(v)), + realArgs: sanitizeValue(this, value), + then: isUI ? undefined : () => trackOrSetValue(this, String(value)), } }, ) diff --git a/tests/document/index.ts b/tests/document/index.ts index 61ec754f..db385b9c 100644 --- a/tests/document/index.ts +++ b/tests/document/index.ts @@ -137,6 +137,30 @@ test('reset UI selection if value is programmatically set', async () => { expect(getUISelection(element)).toHaveProperty('startOffset', 6) }) +test('programmatic `null` value is treated as empty string', async () => { + const {element} = render(``) + + prepare(element) + + // HTMLInputElement.value is a [LegacyNullToEmptyString] DOMString. + // Callers still assign `null` at runtime; the IDL coerces it to `""`. + element.value = null as unknown as string + + expect(element).toHaveValue('') + expect(getUIValue(element)).toBe('') +}) + +test('programmatic `null` value on number input is treated as empty', async () => { + const {element} = render(``) + + prepare(element) + + element.value = null as unknown as string + + expect(element).toHaveValue(null) + expect(getUIValue(element)).toBe('') +}) + test('clear UI selection if selection is programmatically set', async () => { const {element} = render(``)