Fix numeric controlled input values in front components (#23421)

A front component rendering a numeric input never showed its value
because the caret-preserving path only accepted strings:
- controlled: `<input type="number" value={42}>` was rejected by the
value sync guard, so nothing was written to the host element
- uncontrolled: `defaultValue={42}` was dropped from the initial value
seeding

Numeric values are now stringified in both places. Also adds a test
asserting `createCaretPreservingElement` forwards its ref to the
rendered element.

The controlled case and the ref test were flagged by cubic on #23264
This commit is contained in:
Raphaël Bosi
2026-07-28 14:43:55 +02:00
committed by GitHub
parent 4c904aa44c
commit 64001591f2
4 changed files with 95 additions and 10 deletions
@@ -1,4 +1,4 @@
import { isString } from '@sniptt/guards';
import { isNumber, isString } from '@sniptt/guards';
import { useLayoutEffect, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -24,13 +24,13 @@ export const useCaretPreservingElementRef = (
useLayoutEffect(() => {
const attachedElement = attachedElementRef.current;
if (!isDefined(attachedElement) || !isString(value)) {
if (!isDefined(attachedElement) || (!isString(value) && !isNumber(value))) {
return;
}
syncValuePreservingCaret(
attachedElement as HTMLInputElement | HTMLTextAreaElement,
value,
String(value),
);
});
@@ -1,7 +1,14 @@
import { type ReactElement } from 'react';
import './setupServerRenderingGlobals';
import { act, type ReactElement } from 'react';
import { createRoot } from 'react-dom/client';
import { createCaretPreservingElement } from '../createCaretPreservingElement';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const getProps = (element: ReactElement): Record<string, unknown> =>
element.props as Record<string, unknown>;
@@ -31,6 +38,30 @@ describe('createCaretPreservingElement', () => {
expect(getProps(element).defaultValue).toBe('hello');
});
it('should seed the initial value from a numeric defaultValue', () => {
const element = createCaretPreservingElement({
htmlTag: 'input',
reactBindableProps: { type: 'number', defaultValue: 42 },
hostEnforcedProps: {},
setEditableFocused: null,
caretPreservingElementRef: () => {},
});
expect(getProps(element).defaultValue).toBe('42');
});
it('should seed the initial value from a numeric value', () => {
const element = createCaretPreservingElement({
htmlTag: 'input',
reactBindableProps: { type: 'number', value: 42 },
hostEnforcedProps: {},
setEditableFocused: null,
caretPreservingElementRef: () => {},
});
expect(getProps(element).defaultValue).toBe('42');
});
it('should prefer defaultValue over value for the initial value', () => {
const element = createCaretPreservingElement({
htmlTag: 'input',
@@ -87,4 +118,32 @@ describe('createCaretPreservingElement', () => {
expect(setEditableFocused).toHaveBeenCalledWith(false);
});
it('should forward the caret preserving ref to the rendered element', () => {
const caretPreservingElementRef = jest.fn();
const element = createCaretPreservingElement({
htmlTag: 'input',
reactBindableProps: {},
hostEnforcedProps: {},
setEditableFocused: null,
caretPreservingElementRef,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(element);
});
expect(caretPreservingElementRef).toHaveBeenCalledWith(
container.firstElementChild,
);
act(() => {
root.unmount();
});
container.remove();
});
});
@@ -160,6 +160,23 @@ describe('createHtmlHostWrapper client events', () => {
expect(node.value).toBe('fixed');
});
it('should write a numeric controlled value to the host input', () => {
const Wrapper = createHtmlHostWrapper('input');
act(() => {
root.render(createElement(Wrapper, { type: 'number', value: 42 }));
});
const node = container.firstElementChild as HTMLInputElement;
expect(node.value).toBe('42');
act(() => {
root.render(createElement(Wrapper, { type: 'number', value: 43 }));
});
expect(node.value).toBe('43');
});
it('should clear the host input when a controlled value becomes empty', () => {
const Wrapper = createHtmlHostWrapper('input');
@@ -1,4 +1,4 @@
import { isFunction, isNonEmptyString } from '@sniptt/guards';
import { isFunction, isNonEmptyString, isNumber } from '@sniptt/guards';
import React from 'react';
import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext';
@@ -6,6 +6,18 @@ import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
const resolveInitialValue = (candidate: unknown): string | undefined => {
if (isNonEmptyString(candidate)) {
return candidate;
}
if (isNumber(candidate)) {
return String(candidate);
}
return undefined;
};
type CreateCaretPreservingElementParams = {
htmlTag: 'input' | 'textarea';
reactBindableProps: Record<string, unknown>;
@@ -28,11 +40,8 @@ export const createCaretPreservingElement = ({
onBlur: forwardedOnBlur,
...rest
} = reactBindableProps;
const initialValue = isNonEmptyString(defaultValue)
? defaultValue
: isNonEmptyString(value)
? value
: undefined;
const initialValue =
resolveInitialValue(defaultValue) ?? resolveInitialValue(value);
const handleFocus = (event: React.FocusEvent<CaretPreservingElement>) => {
setEditableFocused?.(true);