From 3c0ae49a23ff549d9d52869c3145b2c42562640d Mon Sep 17 00:00:00 2001
From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com>
Date: Tue, 25 Nov 2025 15:37:37 +0500
Subject: [PATCH] Clicking outside inline fields on record page saves the
value. (#16042)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #15957
The core problem: `handleChange` calls `setDraftValue`, but when blur
fires, `handleClickOutside` runs in the same event turn and uses
the `draftValue` captured in its closure from the last render. React
hasn’t re-rendered yet, so that captured `draftValue` is stale. Recoil
atom writes are synchronous, but the render that would update the
closure happens on the next turn.
I considered deferring `handleClickOutside` to the next tick
(setTimeout/Promise), but that’s a timing hack: it makes focus/blur
ordering unpredictable (inline cells, tables, other listeners), risks
double triggers, and can fire after unmount. Another option was to
duplicate the `handleChange` logic inside `handleClickOutside`
(screenshot), but that defeats having draft state in one place.
The clean solution is to read the current draft from a Recoil snapshot
at call time inside `handleClickOutside`. Snapshot reads pull the latest
atom value (including synchronous `setDraftValue` that just ran) even
before a re-render occurs, so they’re not subject to the stale closure.
That way, blur uses the up-to-date draft without timing hacks or
duplicated logic.
MultiItemFieldInput is used in four places. Each of them contains the
updated code.
---
.../record-field/ui/hooks/useRecordFieldInput.ts | 14 ++++++++++++++
.../ui/meta-types/hooks/useArrayField.ts | 6 ++++--
.../ui/meta-types/hooks/useEmailsField.ts | 4 +++-
.../ui/meta-types/hooks/useLinksField.ts | 4 +++-
.../ui/meta-types/hooks/usePhonesField.ts | 4 +++-
.../input/components/ArrayFieldInput.tsx | 11 +++++++++--
.../input/components/EmailsFieldInput.tsx | 11 +++++++++--
.../input/components/LinksFieldInput.tsx | 11 +++++++++--
.../input/components/MultiItemFieldInput.tsx | 8 +-------
.../input/components/PhonesFieldInput.tsx | 11 +++++++++--
10 files changed, 64 insertions(+), 20 deletions(-)
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useRecordFieldInput.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useRecordFieldInput.ts
index 3e7d1b2117..e63b2363e0 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useRecordFieldInput.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/hooks/useRecordFieldInput.ts
@@ -7,6 +7,19 @@ export const useRecordFieldInput = () => {
const recordFieldInputDraftValueCallbackState =
useRecoilComponentCallbackState(recordFieldInputDraftValueComponentState);
+ const getLatestDraftValue = useRecoilCallback(
+ ({ snapshot }) =>
+ (instanceId: string) =>
+ snapshot
+ .getLoadable(
+ recordFieldInputDraftValueComponentState.atomFamily({
+ instanceId,
+ }),
+ )
+ .getValue() as FieldInputDraftValue,
+ [],
+ );
+
const setDraftValue = useRecoilCallback(
({ set }) =>
(newValue: unknown) => {
@@ -30,6 +43,7 @@ export const useRecordFieldInput = () => {
};
return {
+ getLatestDraftValue,
setDraftValue,
isDraftValueEmpty,
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useArrayField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useArrayField.ts
index a9a1d76892..61020463a9 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useArrayField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useArrayField.ts
@@ -24,7 +24,8 @@ export const useArrayField = () => {
}),
);
- const { setDraftValue } = useRecordFieldInput();
+ const { getLatestDraftValue, setDraftValue } =
+ useRecordFieldInput();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -33,8 +34,9 @@ export const useArrayField = () => {
return {
fieldValue,
fieldDefinition,
- setFieldValue,
draftValue,
+ getLatestDraftValue,
+ setFieldValue,
setDraftValue,
};
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useEmailsField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useEmailsField.ts
index d680c98462..b5e217b2e3 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useEmailsField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useEmailsField.ts
@@ -26,7 +26,8 @@ export const useEmailsField = () => {
}),
);
- const { setDraftValue } = useRecordFieldInput();
+ const { getLatestDraftValue, setDraftValue } =
+ useRecordFieldInput();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -36,6 +37,7 @@ export const useEmailsField = () => {
fieldDefinition,
fieldValue,
draftValue,
+ getLatestDraftValue,
setDraftValue,
setFieldValue,
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useLinksField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useLinksField.ts
index 17f09c75ee..97444c19c5 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useLinksField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useLinksField.ts
@@ -26,7 +26,8 @@ export const useLinksField = () => {
}),
);
- const { setDraftValue } = useRecordFieldInput();
+ const { getLatestDraftValue, setDraftValue } =
+ useRecordFieldInput();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -36,6 +37,7 @@ export const useLinksField = () => {
fieldDefinition,
fieldValue,
draftValue,
+ getLatestDraftValue,
setDraftValue,
setFieldValue,
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/usePhonesField.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/usePhonesField.ts
index 8158d3441b..f0dbe220bf 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/usePhonesField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/usePhonesField.ts
@@ -26,7 +26,8 @@ export const usePhonesField = () => {
}),
);
- const { setDraftValue } = useRecordFieldInput();
+ const { getLatestDraftValue, setDraftValue } =
+ useRecordFieldInput();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -36,6 +37,7 @@ export const usePhonesField = () => {
fieldDefinition,
fieldValue,
draftValue,
+ getLatestDraftValue,
setDraftValue,
setFieldValue,
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx
index d3f28a0639..205842967b 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx
@@ -2,16 +2,22 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts
import { useArrayField } from '@/object-record/record-field/ui/meta-types/hooks/useArrayField';
import { ArrayFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/ArrayFieldMenuItem';
import { MultiItemFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput';
+import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { arraySchema } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
+import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const ArrayFieldInput = () => {
- const { setDraftValue, draftValue, fieldDefinition } = useArrayField();
+ const { getLatestDraftValue, setDraftValue, draftValue, fieldDefinition } =
+ useArrayField();
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
+ const instanceId = useAvailableComponentInstanceIdOrThrow(
+ RecordFieldComponentInstanceContext,
+ );
const arrayItems = useMemo>(
() => (Array.isArray(draftValue) ? draftValue : []),
@@ -32,7 +38,8 @@ export const ArrayFieldInput = () => {
_newValue: any,
event: MouseEvent | TouchEvent,
) => {
- onClickOutside?.({ newValue: draftValue, event });
+ const latestDraftValue = getLatestDraftValue(instanceId);
+ onClickOutside?.({ newValue: latestDraftValue, event });
};
const handleEscape = (_newValue: any) => {
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
index 8456c0d79a..2b023f4521 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
@@ -1,9 +1,11 @@
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useEmailsField } from '@/object-record/record-field/ui/meta-types/hooks/useEmailsField';
import { EmailsFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/EmailsFieldMenuItem';
+import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
import { emailsSchema } from '@/object-record/record-field/ui/types/guards/isFieldEmailsValue';
import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema';
+import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useMemo } from 'react';
@@ -14,11 +16,15 @@ import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { MultiItemFieldInput } from './MultiItemFieldInput';
export const EmailsFieldInput = () => {
- const { setDraftValue, draftValue, fieldDefinition } = useEmailsField();
+ const { getLatestDraftValue, setDraftValue, draftValue, fieldDefinition } =
+ useEmailsField();
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
+ const instanceId = useAvailableComponentInstanceIdOrThrow(
+ RecordFieldComponentInstanceContext,
+ );
const emails = useMemo(
() =>
@@ -72,7 +78,8 @@ export const EmailsFieldInput = () => {
_newValue: any,
event: MouseEvent | TouchEvent,
) => {
- onClickOutside?.({ newValue: draftValue, event });
+ const latestDraftValue = getLatestDraftValue(instanceId);
+ onClickOutside?.({ newValue: latestDraftValue, event });
};
const handleEscape = (_newValue: any) => {
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx
index ebeff23e3b..911a720667 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx
@@ -2,8 +2,10 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts
import { useLinksField } from '@/object-record/record-field/ui/meta-types/hooks/useLinksField';
import { LinksFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/LinksFieldMenuItem';
import { getFieldLinkDefinedLinks } from '@/object-record/record-field/ui/meta-types/input/utils/getFieldLinkDefinedLinks';
+import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
import { linksSchema } from '@/object-record/record-field/ui/types/guards/isFieldLinksValue';
+import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
@@ -12,7 +14,11 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { MultiItemFieldInput } from './MultiItemFieldInput';
export const LinksFieldInput = () => {
- const { draftValue, fieldDefinition, setDraftValue } = useLinksField();
+ const { getLatestDraftValue, draftValue, fieldDefinition, setDraftValue } =
+ useLinksField();
+ const instanceId = useAvailableComponentInstanceIdOrThrow(
+ RecordFieldComponentInstanceContext,
+ );
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
@@ -55,7 +61,8 @@ export const LinksFieldInput = () => {
_newValue: any,
event: MouseEvent | TouchEvent,
) => {
- onClickOutside?.({ newValue: draftValue, event });
+ const latestDraftValue = getLatestDraftValue(instanceId);
+ onClickOutside?.({ newValue: latestDraftValue, event });
};
const handleEscape = (_newValue: any) => {
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
index 41b4dfd289..43764b4942 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
@@ -74,13 +74,7 @@ export const MultiItemFieldInput = ({
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
- const isEditing = inputValue !== '';
- const isPrimaryItem = items.length === 0;
-
- if (isEditing && isPrimaryItem) {
- handleSubmitInput();
- }
-
+ handleSubmitInput();
onClickOutside?.(items, event);
},
listenerId: instanceId,
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx
index 69ae037f61..c52eff4cd2 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx
@@ -12,8 +12,10 @@ import { MultiItemFieldInput } from './MultiItemFieldInput';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { createPhonesFromFieldValue } from '@/object-record/record-field/ui/meta-types/input/utils/phonesUtils';
+import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { phonesSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
+import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { css } from '@emotion/react';
import { useContext } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
@@ -73,11 +75,15 @@ const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
`;
export const PhonesFieldInput = () => {
- const { fieldDefinition, setDraftValue, draftValue } = usePhonesField();
+ const { getLatestDraftValue, fieldDefinition, setDraftValue, draftValue } =
+ usePhonesField();
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
const phones = createPhonesFromFieldValue(draftValue);
+ const instanceId = useAvailableComponentInstanceIdOrThrow(
+ RecordFieldComponentInstanceContext,
+ );
const defaultCountry = stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
@@ -131,7 +137,8 @@ export const PhonesFieldInput = () => {
_newValue: any,
event: MouseEvent | TouchEvent,
) => {
- onClickOutside?.({ newValue: draftValue, event });
+ const latestDraftValue = getLatestDraftValue(instanceId);
+ onClickOutside?.({ newValue: latestDraftValue, event });
};
const handleEscape = (_newValue: any) => {