From 64001591f29fffce37d8935878c3153d6054c9ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:43:55 +0200
Subject: [PATCH] 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: `` 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
---
.../hooks/useCaretPreservingElementRef.ts | 6 +-
.../createCaretPreservingElement.test.ts | 61 ++++++++++++++++++-
.../__tests__/createHtmlHostWrapper.test.ts | 17 ++++++
.../utils/createCaretPreservingElement.ts | 21 +++++--
4 files changed, 95 insertions(+), 10 deletions(-)
diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts b/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts
index 49c4996f36..885cc2145b 100644
--- a/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts
+++ b/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts
@@ -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),
);
});
diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts
index 02d6d69ae8..fcc645ecc5 100644
--- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts
@@ -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 =>
element.props as Record;
@@ -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();
+ });
});
diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts
index 547c95dc8a..e99a5bde02 100644
--- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts
@@ -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');
diff --git a/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts b/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts
index 3c227060d5..71340f02b1 100644
--- a/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts
+++ b/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts
@@ -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;
@@ -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) => {
setEditableFocused?.(true);