From 6ce7d04f9d5a04d4ccbaab43cadb4dcb013f1d6d Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Mon, 22 Jun 2026 17:45:49 +0200 Subject: [PATCH] Fix field widget textarea focus reset (#21959) Short description: Keeps the field widget textarea on a local draft value while focused so record-store rewrites do not reset the active caret. # Before Typing in an editor-mode text field can lose caret position when the global record store is rewritten by an external record update/refetch. https://github.com/user-attachments/assets/1ee7a819-2c27-4d09-aae5-814c2cf27181 # After The focused textarea should preserve the in-progress draft and caret while still updating sibling previews optimistically and flushing the final value on blur. https://github.com/user-attachments/assets/41832493-021f-46e8-bc75-722bbb1cd7b7 Review in cubic --- .../components/FieldWidgetTextEditor.tsx | 42 +++++- .../__tests__/FieldWidgetTextEditor.test.tsx | 142 ++++++++++++++++++ .../modules/ui/input/components/TextArea.tsx | 4 + .../components/__tests__/TextArea.test.tsx | 34 +++++ 4 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/components/__tests__/FieldWidgetTextEditor.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/__tests__/TextArea.test.tsx diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx index 68c0274e72..ddd59415cf 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetTextEditor.tsx @@ -7,7 +7,7 @@ import { recordStoreFamilySelector } from '@/object-record/record-store/states/s import { TextArea } from '@/ui/input/components/TextArea'; import { useAtomFamilySelectorState } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorState'; import { styled } from '@linaria/react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { useDebouncedCallback } from 'use-debounce'; @@ -46,6 +46,11 @@ export const FieldWidgetTextEditor = ({ }); const textAreaId = `field-widget-text-editor-${recordId}-${fieldName}`; + const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : ''; + + const [draftText, setDraftText] = useState(fieldTextValue); + const [isFocused, setIsFocused] = useState(false); + const [isDraftDirty, setIsDraftDirty] = useState(false); const persistTextDebounced = useDebouncedCallback((text: string) => { if (isRecordFieldReadOnly === true) { @@ -63,12 +68,28 @@ export const FieldWidgetTextEditor = ({ useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]); + useEffect(() => { + if (isFocused) { + return; + } + + setDraftText(fieldTextValue); + setIsDraftDirty(false); + }, [fieldTextValue, isFocused]); + + const handleFocus = useCallback(() => { + setIsFocused(true); + setIsDraftDirty(false); + }, []); + const handleChange = useCallback( (text: string) => { if (isRecordFieldReadOnly === true) { return; } + setDraftText(text); + setIsDraftDirty(true); setFieldValue(text); persistTextDebounced(text); @@ -77,18 +98,29 @@ export const FieldWidgetTextEditor = ({ ); const handleBlur = useCallback(() => { - persistTextDebounced.flush(); - }, [persistTextDebounced]); + setIsFocused(false); - const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : ''; + if (isDraftDirty && isRecordFieldReadOnly !== true) { + setFieldValue(draftText); + } + + persistTextDebounced.flush(); + }, [ + draftText, + isDraftDirty, + isRecordFieldReadOnly, + persistTextDebounced, + setFieldValue, + ]); return (