fix(front-component): preserve caret position on controlled input/textarea updates (#20416)
## Problem In the front-component sandbox, typing in the middle of a pre-filled `<input>` or `<textarea>` caused the caret to jump to the end on every keystroke. Characters appeared at the correct position, but editing mid-string was effectively broken. Root cause: the remote-DOM bridge round-trips every keystroke through the worker. By the time the updated `value` prop arrives back at the host, React applies it by setting `inputElement.value = X` directly, which browsers always reset the caret to the end. Typing at the end was unaffected, which is why this went unnoticed in search fields and similar append-only inputs. ## Fix For text-like `<input>` types and `<textarea>`, the `value` prop is now applied imperatively through a ref callback instead of being passed as a React controlled prop: - If the DOM value already matches the incoming prop, the assignment is skipped entirely. - If a write is needed and the element is focused, `selectionStart` and `selectionEnd` are captured before the assignment and restored afterwards with `setSelectionRange`. Non-text input types (checkbox, radio, file, color, range) and all other host elements are unaffected. ## Testing Drop the repro from the issue into any front-component, click between two characters in the pre-filled value, and type — the caret should now stay at the insertion point. Fixes #20409 --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+1
@@ -86,6 +86,7 @@ const STORY_COMPONENTS = [
|
||||
'form-events.front-component',
|
||||
'keyboard-events.front-component',
|
||||
'host-api-calls.front-component',
|
||||
'caret-preservation.front-component',
|
||||
];
|
||||
|
||||
const resolveEntryPoints = (): Record<string, string> => {
|
||||
|
||||
@@ -317,6 +317,107 @@ export const HostApiProgress: Story = createHostApiStory(
|
||||
},
|
||||
);
|
||||
|
||||
const TYPING_TIMEOUT = 10000;
|
||||
|
||||
const expectCaretAt = async (
|
||||
element: HTMLInputElement | HTMLTextAreaElement,
|
||||
position: number,
|
||||
): Promise<void> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(element.selectionStart).toBe(position);
|
||||
expect(element.selectionEnd).toBe(position);
|
||||
},
|
||||
{ timeout: TYPING_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const InputCaretPreservedMidString: Story = createComponentStory(
|
||||
'caret-preservation',
|
||||
{
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'caret-preservation-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const input = (await canvas.findByTestId(
|
||||
'caret-text-input',
|
||||
)) as HTMLInputElement;
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(input.value).toBe('Hello world');
|
||||
},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
);
|
||||
|
||||
input.focus();
|
||||
input.setSelectionRange(4, 4);
|
||||
|
||||
await userEvent.keyboard('X');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(input.value).toBe('HellXo world');
|
||||
expect(canvas.getByTestId('caret-text-value').textContent).toBe(
|
||||
'HellXo world',
|
||||
);
|
||||
},
|
||||
{ timeout: TYPING_TIMEOUT },
|
||||
);
|
||||
|
||||
await expectCaretAt(input, 5);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const TextareaCaretPreservedMidString: Story = createComponentStory(
|
||||
'caret-preservation',
|
||||
{
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'caret-preservation-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const textarea = (await canvas.findByTestId(
|
||||
'caret-textarea-input',
|
||||
)) as HTMLTextAreaElement;
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(textarea.value).toBe('Hello world');
|
||||
},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
);
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(4, 4);
|
||||
|
||||
await userEvent.keyboard('X');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(textarea.value).toBe('HellXo world');
|
||||
expect(canvas.getByTestId('caret-textarea-value').textContent).toBe(
|
||||
'HellXo world',
|
||||
);
|
||||
},
|
||||
{ timeout: TYPING_TIMEOUT },
|
||||
);
|
||||
|
||||
await expectCaretAt(textarea, 5);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const HostApiClosePanel: Story = createHostApiStory(
|
||||
async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
backgroundColor: '#eff6ff',
|
||||
border: '2px solid #3b82f6',
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 16,
|
||||
maxWidth: 400,
|
||||
};
|
||||
|
||||
const HEADING_STYLE = {
|
||||
color: '#1e3a8a',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const LABEL_STYLE = {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
};
|
||||
|
||||
const HINT_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const INPUT_STYLE = {
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const INITIAL_VALUE = 'Hello world';
|
||||
|
||||
const CaretPreservationComponent = () => {
|
||||
const [text, setText] = useState(INITIAL_VALUE);
|
||||
const [textareaText, setTextareaText] = useState(INITIAL_VALUE);
|
||||
|
||||
return (
|
||||
<div data-testid="caret-preservation-component" style={CARD_STYLE}>
|
||||
<h2 style={HEADING_STYLE}>Caret Preservation</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Text input (pre-filled)</label>
|
||||
<input
|
||||
data-testid="caret-text-input"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const detail = (event as unknown as { detail: { value?: string } })
|
||||
.detail;
|
||||
setText(detail?.value ?? '');
|
||||
}}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
<span data-testid="caret-text-value" style={HINT_STYLE}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Textarea (pre-filled)</label>
|
||||
<textarea
|
||||
data-testid="caret-textarea-input"
|
||||
value={textareaText}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const detail = (event as unknown as { detail: { value?: string } })
|
||||
.detail;
|
||||
setTextareaText(detail?.value ?? '');
|
||||
}}
|
||||
style={INPUT_STYLE}
|
||||
rows={3}
|
||||
/>
|
||||
<span data-testid="caret-textarea-value" style={HINT_STYLE}>
|
||||
{textareaText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-caret-00000000-0000-0000-0000-000000000021',
|
||||
name: 'caret-preservation-component',
|
||||
description:
|
||||
'Component verifying caret position is preserved during mid-string editing of <input>/<textarea>',
|
||||
component: CaretPreservationComponent,
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
import {
|
||||
isBoolean,
|
||||
isFunction,
|
||||
isNonEmptyString,
|
||||
isNumber,
|
||||
isObject,
|
||||
isString,
|
||||
isUndefined,
|
||||
} from '@sniptt/guards';
|
||||
import React from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
||||
import { type SerializedEventData } from '@/constants/SerializedEventData';
|
||||
@@ -31,7 +41,7 @@ const VOID_ELEMENTS = new Set([
|
||||
const parseCssString = (
|
||||
styleString: string | undefined,
|
||||
): React.CSSProperties | undefined => {
|
||||
if (!styleString || typeof styleString !== 'string') {
|
||||
if (!isNonEmptyString(styleString)) {
|
||||
return styleString as React.CSSProperties | undefined;
|
||||
}
|
||||
|
||||
@@ -60,81 +70,62 @@ const parseCssString = (
|
||||
};
|
||||
|
||||
const serializeEvent = (event: unknown): SerializedEventData => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
if (!isObject(event)) {
|
||||
return { type: 'unknown' };
|
||||
}
|
||||
|
||||
const domEvent = event as Record<string, unknown>;
|
||||
const serialized: SerializedEventData = {
|
||||
type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown',
|
||||
type: isString(domEvent.type) ? domEvent.type : 'unknown',
|
||||
};
|
||||
|
||||
if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean;
|
||||
if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean;
|
||||
if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean;
|
||||
if ('shiftKey' in domEvent)
|
||||
serialized.shiftKey = domEvent.shiftKey as boolean;
|
||||
if (isBoolean(domEvent.altKey)) serialized.altKey = domEvent.altKey;
|
||||
if (isBoolean(domEvent.ctrlKey)) serialized.ctrlKey = domEvent.ctrlKey;
|
||||
if (isBoolean(domEvent.metaKey)) serialized.metaKey = domEvent.metaKey;
|
||||
if (isBoolean(domEvent.shiftKey)) serialized.shiftKey = domEvent.shiftKey;
|
||||
|
||||
if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number;
|
||||
if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number;
|
||||
if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number;
|
||||
if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number;
|
||||
if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number;
|
||||
if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number;
|
||||
if ('offsetX' in domEvent) serialized.offsetX = domEvent.offsetX as number;
|
||||
if ('offsetY' in domEvent) serialized.offsetY = domEvent.offsetY as number;
|
||||
if ('movementX' in domEvent)
|
||||
serialized.movementX = domEvent.movementX as number;
|
||||
if ('movementY' in domEvent)
|
||||
serialized.movementY = domEvent.movementY as number;
|
||||
if ('button' in domEvent) serialized.button = domEvent.button as number;
|
||||
if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number;
|
||||
if (isNumber(domEvent.clientX)) serialized.clientX = domEvent.clientX;
|
||||
if (isNumber(domEvent.clientY)) serialized.clientY = domEvent.clientY;
|
||||
if (isNumber(domEvent.pageX)) serialized.pageX = domEvent.pageX;
|
||||
if (isNumber(domEvent.pageY)) serialized.pageY = domEvent.pageY;
|
||||
if (isNumber(domEvent.screenX)) serialized.screenX = domEvent.screenX;
|
||||
if (isNumber(domEvent.screenY)) serialized.screenY = domEvent.screenY;
|
||||
if (isNumber(domEvent.offsetX)) serialized.offsetX = domEvent.offsetX;
|
||||
if (isNumber(domEvent.offsetY)) serialized.offsetY = domEvent.offsetY;
|
||||
if (isNumber(domEvent.movementX)) serialized.movementX = domEvent.movementX;
|
||||
if (isNumber(domEvent.movementY)) serialized.movementY = domEvent.movementY;
|
||||
if (isNumber(domEvent.button)) serialized.button = domEvent.button;
|
||||
if (isNumber(domEvent.buttons)) serialized.buttons = domEvent.buttons;
|
||||
|
||||
if ('key' in domEvent) serialized.key = domEvent.key as string;
|
||||
if ('code' in domEvent) serialized.code = domEvent.code as string;
|
||||
if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean;
|
||||
if (isString(domEvent.key)) serialized.key = domEvent.key;
|
||||
if (isString(domEvent.code)) serialized.code = domEvent.code;
|
||||
if (isBoolean(domEvent.repeat)) serialized.repeat = domEvent.repeat;
|
||||
|
||||
if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number;
|
||||
if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number;
|
||||
if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number;
|
||||
if ('deltaMode' in domEvent)
|
||||
serialized.deltaMode = domEvent.deltaMode as number;
|
||||
if (isNumber(domEvent.deltaX)) serialized.deltaX = domEvent.deltaX;
|
||||
if (isNumber(domEvent.deltaY)) serialized.deltaY = domEvent.deltaY;
|
||||
if (isNumber(domEvent.deltaZ)) serialized.deltaZ = domEvent.deltaZ;
|
||||
if (isNumber(domEvent.deltaMode)) serialized.deltaMode = domEvent.deltaMode;
|
||||
|
||||
const target = domEvent.target as Record<string, unknown> | undefined;
|
||||
if (target && typeof target === 'object') {
|
||||
if ('value' in target && typeof target.value === 'string') {
|
||||
serialized.value = target.value;
|
||||
}
|
||||
if ('checked' in target && typeof target.checked === 'boolean') {
|
||||
serialized.checked = target.checked;
|
||||
}
|
||||
if ('scrollTop' in target && typeof target.scrollTop === 'number') {
|
||||
serialized.scrollTop = target.scrollTop;
|
||||
}
|
||||
if ('scrollLeft' in target && typeof target.scrollLeft === 'number') {
|
||||
serialized.scrollLeft = target.scrollLeft;
|
||||
}
|
||||
if ('currentTime' in target && typeof target.currentTime === 'number') {
|
||||
serialized.currentTime = target.currentTime;
|
||||
}
|
||||
if ('duration' in target && typeof target.duration === 'number') {
|
||||
serialized.duration = target.duration;
|
||||
}
|
||||
if ('paused' in target && typeof target.paused === 'boolean') {
|
||||
serialized.paused = target.paused;
|
||||
}
|
||||
if ('ended' in target && typeof target.ended === 'boolean') {
|
||||
serialized.ended = target.ended;
|
||||
}
|
||||
if ('volume' in target && typeof target.volume === 'number') {
|
||||
serialized.volume = target.volume;
|
||||
}
|
||||
if ('muted' in target && typeof target.muted === 'boolean') {
|
||||
serialized.muted = target.muted;
|
||||
}
|
||||
if ('playbackRate' in target && typeof target.playbackRate === 'number') {
|
||||
serialized.playbackRate = target.playbackRate;
|
||||
}
|
||||
const target = domEvent.target;
|
||||
if (isObject(target)) {
|
||||
const targetRecord = target as Record<string, unknown>;
|
||||
if (isString(targetRecord.value)) serialized.value = targetRecord.value;
|
||||
if (isBoolean(targetRecord.checked))
|
||||
serialized.checked = targetRecord.checked;
|
||||
if (isNumber(targetRecord.scrollTop))
|
||||
serialized.scrollTop = targetRecord.scrollTop;
|
||||
if (isNumber(targetRecord.scrollLeft))
|
||||
serialized.scrollLeft = targetRecord.scrollLeft;
|
||||
if (isNumber(targetRecord.currentTime))
|
||||
serialized.currentTime = targetRecord.currentTime;
|
||||
if (isNumber(targetRecord.duration))
|
||||
serialized.duration = targetRecord.duration;
|
||||
if (isBoolean(targetRecord.paused)) serialized.paused = targetRecord.paused;
|
||||
if (isBoolean(targetRecord.ended)) serialized.ended = targetRecord.ended;
|
||||
if (isNumber(targetRecord.volume)) serialized.volume = targetRecord.volume;
|
||||
if (isBoolean(targetRecord.muted)) serialized.muted = targetRecord.muted;
|
||||
if (isNumber(targetRecord.playbackRate))
|
||||
serialized.playbackRate = targetRecord.playbackRate;
|
||||
}
|
||||
|
||||
return serialized;
|
||||
@@ -150,14 +141,14 @@ const filterProps = <T extends object>(props: T): T => {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (INTERNAL_PROPS.has(key) || value === undefined) continue;
|
||||
if (INTERNAL_PROPS.has(key) || isUndefined(value)) continue;
|
||||
|
||||
if (key === 'style') {
|
||||
filtered.style = parseCssString(value as string | undefined);
|
||||
} else {
|
||||
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
|
||||
|
||||
if (normalizedKey.startsWith('on') && typeof value === 'function') {
|
||||
if (normalizedKey.startsWith('on') && isFunction(value)) {
|
||||
filtered[normalizedKey] = wrapEventHandler(
|
||||
value as (detail: SerializedEventData) => void,
|
||||
);
|
||||
@@ -176,14 +167,86 @@ const FORCED_PROPS_BY_TAG: Record<string, Record<string, unknown>> = {
|
||||
iframe: { sandbox: '' },
|
||||
};
|
||||
|
||||
export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
||||
const forcedProps = FORCED_PROPS_BY_TAG[htmlTag];
|
||||
const TEXT_LIKE_INPUT_TYPES = new Set([
|
||||
'text',
|
||||
'search',
|
||||
'url',
|
||||
'tel',
|
||||
'password',
|
||||
'email',
|
||||
'number',
|
||||
'',
|
||||
]);
|
||||
|
||||
return ({ children, ...props }: WrapperProps) =>
|
||||
React.createElement(
|
||||
const isTextLikeInputType = (type: unknown): boolean => {
|
||||
const inputType = isString(type) ? type.toLowerCase() : '';
|
||||
return TEXT_LIKE_INPUT_TYPES.has(inputType);
|
||||
};
|
||||
|
||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
const syncValuePreservingCaret = (
|
||||
element: CaretPreservingElement,
|
||||
nextValue: string,
|
||||
): void => {
|
||||
if (element.value === nextValue) return;
|
||||
|
||||
const isFocused = document.activeElement === element;
|
||||
const start = isFocused ? element.selectionStart : null;
|
||||
const end = isFocused ? element.selectionEnd : null;
|
||||
|
||||
element.value = nextValue;
|
||||
|
||||
if (isFocused && isDefined(start) && isDefined(end)) {
|
||||
try {
|
||||
element.setSelectionRange(start, end);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const createCaretPreservingElement = (
|
||||
htmlTag: 'input' | 'textarea',
|
||||
reactProps: Record<string, unknown>,
|
||||
forcedProps: Record<string, unknown> | undefined,
|
||||
) => {
|
||||
const { value, defaultValue, ...rest } = reactProps;
|
||||
const initialValue = isNonEmptyString(defaultValue)
|
||||
? defaultValue
|
||||
: isNonEmptyString(value)
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
return React.createElement(htmlTag, {
|
||||
...rest,
|
||||
...forcedProps,
|
||||
defaultValue: initialValue,
|
||||
ref: (node: CaretPreservingElement | null) => {
|
||||
if (!isDefined(node)) return;
|
||||
if (isNonEmptyString(value)) {
|
||||
syncValuePreservingCaret(node, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
const forcedProps = FORCED_PROPS_BY_TAG[htmlTag];
|
||||
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
||||
|
||||
return ({ children, ...props }: WrapperProps) => {
|
||||
const reactProps = filterProps(props);
|
||||
|
||||
if (
|
||||
htmlTag === 'textarea' ||
|
||||
(htmlTag === 'input' && isTextLikeInputType(reactProps.type))
|
||||
) {
|
||||
return createCaretPreservingElement(htmlTag, reactProps, forcedProps);
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
htmlTag,
|
||||
{ ...filterProps(props), ...forcedProps },
|
||||
{ ...reactProps, ...forcedProps },
|
||||
isVoid ? undefined : children,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user