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:
+14
@@ -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,
|
||||
};
|
||||
|
||||
+4
-2
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+3
-1
@@ -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,
|
||||
};
|
||||
|
||||
+3
-1
@@ -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,
|
||||
};
|
||||
|
||||
+3
-1
@@ -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,
|
||||
};
|
||||
|
||||
+9
-2
@@ -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) => {
|
||||
|
||||
+9
-2
@@ -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) => {
|
||||
|
||||
+9
-2
@@ -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) => {
|
||||
|
||||
+1
-7
@@ -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,
|
||||
|
||||
+9
-2
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user