From 429e8c4b8463b604a72d0bd4b3a28eaaa8fe5fd4 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:48:09 +0530 Subject: [PATCH] fix: align email validation between front and server and roll back optimistic value on failed save (#22490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Inline edits of EMAILS fields could leave the UI in a misleading state: the frontend validated with Zod's default `z.email()` while the server used the stricter `z.regexes.unicodeEmail` pattern (which caps the local part at 64 characters). A very long email passed client validation and was optimistically written to the UI; the server then rejected the mutation. An error snackbar was shown, but the field kept displaying the unsaved value until a page reload. ## Changes - **Single source of truth for email validation**: added a shared `emailSchema` (`z.email({ pattern: z.regexes.unicodeEmail })`) in `twenty-shared/utils`, now used by: - the server-side EMAILS field validator (`validate-emails-primary-email-subfield-or-throw.util.ts`) - the `EmailsFieldInput` inline editor - spreadsheet import validation - **Rollback on failed save**: `useUpdateOneRecord` now restores the optimistically updated fields in the record store when the mutation fails, mirroring the store upsert already done in the success path. Previously the catch block only rolled back the Apollo cache — which stopped reverting the UI after table virtualization, since the record store (the render source of truth) is no longer synced reactively from the cache. The error is still rethrown, so the existing global promise-rejection handler keeps showing the error snackbar. This fixes the stale-value-until-reload behavior for all field types and all callers, not just EMAILS fields. - **Regression tests**: added unit tests for the shared schema, including the >64-character local part case. Fixes [sonarly issue #54034](https://sonarly.com/issue/54034?share=eyJ0aWQiOjMzMCwidHlwIjoiYnVnIiwicmlkIjo1NDAzNCwiZXhwIjoxNzgzNTI1OTQzfQ.9e7639034a677301512fceeafab764b1) Review in cubic --- .../object-record/hooks/useUpdateOneRecord.ts | 24 ++++++++++++++++- .../input/components/EmailsFieldInput.tsx | 3 +-- .../ui/validation-schemas/emailSchema.ts | 3 --- ...etSpreadSheetFieldValidationDefinitions.ts | 2 +- ...ls-primary-email-subfield-or-throw.util.ts | 7 ++--- packages/twenty-shared/src/utils/index.ts | 1 + .../validation/__tests__/emailSchema.test.ts | 27 +++++++++++++++++++ .../src/utils/validation/emailSchema.ts | 3 +++ 8 files changed, 58 insertions(+), 12 deletions(-) delete mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts create mode 100644 packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts create mode 100644 packages/twenty-shared/src/utils/validation/emailSchema.ts diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index a362a324dd..d51161409a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -20,8 +20,8 @@ import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeO import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput'; import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; -import { isNull } from '@sniptt/guards'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNull } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; @@ -239,6 +239,28 @@ export const useUpdateOneRecord = () => { upsertRecordsInStore, }); + const optimisticallyUpdatedFieldsToRestore = Object.keys( + optimisticRecordInput, + ).reduce>( + (restoredFields, fieldName) => ({ + ...restoredFields, + [fieldName]: cachedRecordKeys.has(fieldName) + ? cachedRecord?.[fieldName] + : null, + }), + {}, + ); + + upsertRecordsInStore({ + partialRecords: [ + { + id: idToUpdate, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + ...optimisticallyUpdatedFieldsToRestore, + }, + ], + }); + throw error; }); 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 466a21bbe4..1c9222e91c 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 @@ -5,12 +5,11 @@ import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/recor import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState'; import { type FieldEmailsValue } from '@/object-record/record-field/ui/types/FieldMetadata'; import { emailsFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/emailsFieldValueSchema'; -import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useLingui } from '@lingui/react/macro'; import { useCallback, useContext, useMemo } from 'react'; import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; +import { emailSchema, isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; import { MultiItemFieldInput } from './MultiItemFieldInput'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts deleted file mode 100644 index adcbf8eaf0..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/validation-schemas/emailSchema.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { z } from 'zod'; - -export const emailSchema = z.email(); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts index e36d71cb6e..48d3304a16 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts @@ -1,5 +1,4 @@ import { isFieldRatingValue } from '@/object-record/record-field/ui/types/guards/isFieldRatingValue'; -import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema'; import { type SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types'; import { t } from '@lingui/core/macro'; import { isDate, isString } from '@sniptt/guards'; @@ -7,6 +6,7 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'; import { RATING_VALUES } from 'twenty-shared/constants'; import { absoluteUrlSchema, + emailSchema, getCountryCodesForCallingCode, isDefined, isValidCountryCode, diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts index 6fa6495747..92b35cd97a 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-emails-primary-email-subfield-or-throw.util.ts @@ -2,7 +2,7 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isNonEmptyString, isNull } from '@sniptt/guards'; -import { z } from 'zod'; +import { emailSchema } from 'twenty-shared/utils'; import { CommonQueryRunnerException, @@ -25,10 +25,7 @@ export const validateEmailsPrimaryEmailSubfieldOrThrow = ( ); } - if ( - !z.email({ pattern: z.regexes.unicodeEmail }).safeParse(value).success && - isNonEmptyString(value) - ) { + if (!emailSchema.safeParse(value).success && isNonEmptyString(value)) { const inspectedValue = inspect(value); throw new CommonQueryRunnerException( diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 7bbd88ecc4..a06f426d46 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -226,6 +226,7 @@ export { normalizeUrlOrigin } from './url/normalizeUrlOrigin'; export { safeDecodeURIComponent } from './url/safeDecodeURIComponent'; export { uuidToBase36 } from './uuidToBase36'; export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow'; +export { emailSchema } from './validation/emailSchema'; export { isDefined } from './validation/isDefined'; export { isEmptyObject } from './validation/isEmptyObject'; export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes'; diff --git a/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts new file mode 100644 index 0000000000..12a30290c8 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/__tests__/emailSchema.test.ts @@ -0,0 +1,27 @@ +import { emailSchema } from '@/utils/validation/emailSchema'; + +describe('emailSchema', () => { + it('should accept valid emails', () => { + expect(emailSchema.safeParse('john.doe@example.com').success).toBe(true); + expect(emailSchema.safeParse('jöhn@example.com').success).toBe(true); + expect(emailSchema.safeParse('john+tag@sub.example.co').success).toBe(true); + }); + + it('should reject invalid emails', () => { + expect(emailSchema.safeParse('not-an-email').success).toBe(false); + expect(emailSchema.safeParse('john@').success).toBe(false); + expect(emailSchema.safeParse('@example.com').success).toBe(false); + expect(emailSchema.safeParse('john doe@example.com').success).toBe(false); + }); + + it('should reject emails with a local part longer than 64 characters', () => { + const longLocalPart = 'a'.repeat(65); + + expect(emailSchema.safeParse(`${longLocalPart}@example.com`).success).toBe( + false, + ); + expect(emailSchema.safeParse(`${'a'.repeat(64)}@example.com`).success).toBe( + true, + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/emailSchema.ts b/packages/twenty-shared/src/utils/validation/emailSchema.ts new file mode 100644 index 0000000000..316564bf85 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/emailSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const emailSchema = z.email({ pattern: z.regexes.unicodeEmail });