Clicking outside inline fields on record page saves the value. (#16042)

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.

<p align="center">
<img width="496" height="332" alt="const nextSecondaryLinks =
updatedLinks slice (1);"
src="https://github.com/user-attachments/assets/638e2bcf-871b-4b53-9124-c8489c5db530"
/>
</p>

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.
This commit is contained in:
Abdullah.
2025-11-25 15:37:37 +05:00
committed by GitHub
parent 3e584c3d5f
commit 3c0ae49a23
10 changed files with 64 additions and 20 deletions
@@ -7,6 +7,19 @@ export const useRecordFieldInput = <FieldValue>() => {
const recordFieldInputDraftValueCallbackState =
useRecoilComponentCallbackState(recordFieldInputDraftValueComponentState);
const getLatestDraftValue = useRecoilCallback(
({ snapshot }) =>
(instanceId: string) =>
snapshot
.getLoadable(
recordFieldInputDraftValueComponentState.atomFamily({
instanceId,
}),
)
.getValue() as FieldInputDraftValue<FieldValue>,
[],
);
const setDraftValue = useRecoilCallback(
({ set }) =>
(newValue: unknown) => {
@@ -30,6 +43,7 @@ export const useRecordFieldInput = <FieldValue>() => {
};
return {
getLatestDraftValue,
setDraftValue,
isDraftValueEmpty,
};
@@ -24,7 +24,8 @@ export const useArrayField = () => {
}),
);
const { setDraftValue } = useRecordFieldInput<FieldArrayValue>();
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldArrayValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -33,8 +34,9 @@ export const useArrayField = () => {
return {
fieldValue,
fieldDefinition,
setFieldValue,
draftValue,
getLatestDraftValue,
setFieldValue,
setDraftValue,
};
};
@@ -26,7 +26,8 @@ export const useEmailsField = () => {
}),
);
const { setDraftValue } = useRecordFieldInput<FieldEmailsValue>();
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldEmailsValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -36,6 +37,7 @@ export const useEmailsField = () => {
fieldDefinition,
fieldValue,
draftValue,
getLatestDraftValue,
setDraftValue,
setFieldValue,
};
@@ -26,7 +26,8 @@ export const useLinksField = () => {
}),
);
const { setDraftValue } = useRecordFieldInput<FieldLinksValue>();
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldLinksValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -36,6 +37,7 @@ export const useLinksField = () => {
fieldDefinition,
fieldValue,
draftValue,
getLatestDraftValue,
setDraftValue,
setFieldValue,
};
@@ -26,7 +26,8 @@ export const usePhonesField = () => {
}),
);
const { setDraftValue } = useRecordFieldInput<FieldPhonesValue>();
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldPhonesValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -36,6 +37,7 @@ export const usePhonesField = () => {
fieldDefinition,
fieldValue,
draftValue,
getLatestDraftValue,
setDraftValue,
setFieldValue,
};
@@ -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<string>>(
() => (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) => {
@@ -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<string[]>(
() =>
@@ -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) => {
@@ -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) => {
@@ -74,13 +74,7 @@ export const MultiItemFieldInput = <T,>({
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
const isEditing = inputValue !== '';
const isPrimaryItem = items.length === 0;
if (isEditing && isPrimaryItem) {
handleSubmitInput();
}
handleSubmitInput();
onClickOutside?.(items, event);
},
listenerId: instanceId,
@@ -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) => {