From bab771b71821ceb69da71b1b11a02fad65b101e4 Mon Sep 17 00:00:00 2001 From: sonarly-bot Date: Fri, 22 May 2026 20:09:04 +0000 Subject: [PATCH] fix: ignore unknown fields in SSE optimistic update path https://sonarly.com/issue/39857?type=bug A frontend runtime error is thrown while processing SSE update events for a custom object, breaking live-update handling on that object page. The error is user-visible and blocks normal interaction flow for affected records/workspaces. Fix: Implemented a targeted frontend fix for the SSE optimistic update crash caused by unknown custom-object fields: 1) Extracted unknown-field detection into a reusable helper: - Added `getUnknownOptimisticRecordInputFields(...)` in `computeOptimisticRecordFromInput.ts`. - `computeOptimisticRecordFromInput(...)` now reuses that helper and keeps existing strict throw behavior unchanged for its normal callers. 2) Fixed SSE UPDATED event handling to be resilient to metadata/data drift: - In `useTriggerOptimisticEffectFromSseUpdateEvents.ts`, before optimistic computation, I compute unknown fields and sanitize `updatedRecord` by removing unknown keys. - The sanitized payload is then used for store upsert and optimistic record computation, preventing the previous throw path from breaking SSE processing. 3) Added test coverage for the extracted helper: - In `computeOptimisticRecordFromInput.test.ts`, added a test asserting unknown fields are correctly identified while known fields are excluded. - Existing strict throw test remains intact, confirming strict mode still applies outside the SSE sanitization path. Authored by Sonarly by autonomous analysis (run 45433). --- .../computeOptimisticRecordFromInput.test.ts | 21 ++++- .../utils/computeOptimisticRecordFromInput.ts | 79 +++++++++++-------- .../hooks/useTriggerEventStreamCreation.ts | 14 ++++ ...ggerOptimisticEffectFromSseUpdateEvents.ts | 25 +++++- 4 files changed, 100 insertions(+), 39 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts index e3e1dddc894..0d4df4cbad3 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeOptimisticRecordFromInput.test.ts @@ -4,7 +4,10 @@ import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFr import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord'; import { type FieldActorForInputValue } from '@/object-record/record-field/ui/types/FieldMetadata'; -import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; +import { + computeOptimisticRecordFromInput, + getUnknownOptimisticRecordInputFields, +} from '@/object-record/utils/computeOptimisticRecordFromInput'; import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; import { InMemoryCache } from '@apollo/client'; import { mockedWorkspaceMemberRecords } from '~/testing/mock-data/generated/data/workspaceMembers/mock-workspaceMembers-data'; @@ -252,6 +255,22 @@ describe('computeOptimisticRecordFromInput', () => { }); }); + it('should compute unknown record input fields unrelated to the current object metadata', () => { + const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person'); + + const unknownRecordInputFields = getUnknownOptimisticRecordInputFields({ + objectMetadataItem: personObjectMetadataItem, + recordInput: { + unknwon: 'unknown', + foo: 'foo', + bar: 'bar', + city: 'Paris', + }, + }); + + expect(unknownRecordInputFields).toEqual(['unknwon', 'foo', 'bar']); + }); + it('should throw an error if recordInput contains fields unrelated to the current objectMetadata', () => { const cache = new InMemoryCache(); const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person'); diff --git a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts index efe8f997898..c75cc03b663 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts @@ -32,6 +32,47 @@ type ComputeOptimisticCacheRecordInputArgs = { GetRecordFromCacheArgs, 'cache' | 'objectMetadataItems' | 'objectPermissionsByObjectMetadataId' >; + +export const getUnknownOptimisticRecordInputFields = ({ + objectMetadataItem, + recordInput, +}: Pick) => { + return Object.keys(recordInput).filter((recordKey) => { + const correspondingFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.name === recordKey, + ); + + const potentialRelationJoinColumnNameFieldMetadataItem = + objectMetadataItem.fields.find( + (field) => + field.type === FieldMetadataType.RELATION && + computeRelationGqlFieldJoinColumnName({ name: field.name }) === + recordKey, + ); + + const potentialMorphRelationJoinColumnNameFieldMetadataItem = + objectMetadataItem.fields.find((field) => { + if (!isFieldMorphRelation(field)) return false; + + return isDefined( + getFieldMetadataFromGqlField({ + objectMetadataItem, + gqlField: recordKey, + }), + ); + }); + + const isUnknownField = + !isDefined(correspondingFieldMetadataItem) && + !isDefined(potentialRelationJoinColumnNameFieldMetadataItem) && + !isDefined(potentialMorphRelationJoinColumnNameFieldMetadataItem); + + const isTypenameField = recordKey === GRAPHQL_TYPENAME_KEY; + + return isUnknownField && !isTypenameField; + }); +}; + export const computeOptimisticRecordFromInput = ({ objectMetadataItem, recordInput, @@ -40,41 +81,11 @@ export const computeOptimisticRecordFromInput = ({ currentWorkspaceMember, objectPermissionsByObjectMetadataId, }: ComputeOptimisticCacheRecordInputArgs) => { - const unknownRecordInputFields = Object.keys(recordInput).filter( - (recordKey) => { - const correspondingFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.name === recordKey, - ); + const unknownRecordInputFields = getUnknownOptimisticRecordInputFields({ + objectMetadataItem, + recordInput, + }); - const potentialRelationJoinColumnNameFieldMetadataItem = - objectMetadataItem.fields.find( - (field) => - field.type === FieldMetadataType.RELATION && - computeRelationGqlFieldJoinColumnName({ name: field.name }) === - recordKey, - ); - - const potentialMorphRelationJoinColumnNameFieldMetadataItem = - objectMetadataItem.fields.find((field) => { - if (!isFieldMorphRelation(field)) return false; - - return isDefined( - getFieldMetadataFromGqlField({ - objectMetadataItem, - gqlField: recordKey, - }), - ); - }); - - const isUnknownField = - !isDefined(correspondingFieldMetadataItem) && - !isDefined(potentialRelationJoinColumnNameFieldMetadataItem) && - !isDefined(potentialMorphRelationJoinColumnNameFieldMetadataItem); - - const isTypenameField = recordKey === GRAPHQL_TYPENAME_KEY; - return isUnknownField && !isTypenameField; - }, - ); if (unknownRecordInputFields.length > 0) { throw new Error( `Should never occur, encountered unknown fields ${unknownRecordInputFields.join(', ')} in objectMetadataItem ${objectMetadataItem.nameSingular}`, diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts index 71d86d3b772..0aa7f1a762e 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerEventStreamCreation.ts @@ -12,6 +12,7 @@ import { sseEventStreamReadyState } from '@/sse-db-event/states/sseEventStreamRe import { isGracefullyHandledEventStreamError } from '@/sse-db-event/utils/isGracefullyHandledEventStreamError'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { captureException } from '@sentry/react'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; import { isNonEmptyString } from '@sniptt/guards'; import { print, type ExecutionResult } from 'graphql'; @@ -131,6 +132,19 @@ export const useTriggerEventStreamCreation = () => { dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents); }, error: (error) => { + if (CombinedGraphQLErrors.is(error)) { + const extensions = getGraphqlErrorExtensionsFromError(error); + + if ( + isGracefullyHandledEventStreamError({ + subCode: extensions?.subCode, + code: extensions?.code, + }) + ) { + return; + } + } + captureException(error); }, complete: () => {}, diff --git a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts index 3117ccf8496..1f6ecbdfa74 100644 --- a/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts +++ b/packages/twenty-front/src/modules/sse-db-event/hooks/useTriggerOptimisticEffectFromSseUpdateEvents.ts @@ -10,7 +10,11 @@ import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { useRefetchAggregateQueriesForObjectMetadataItem } from '@/object-record/hooks/useRefetchAggregateQueriesForObjectMetadataItem'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; -import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { + computeOptimisticRecordFromInput, + getUnknownOptimisticRecordInputFields, +} from '@/object-record/utils/computeOptimisticRecordFromInput'; import { useCallback } from 'react'; import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { @@ -45,18 +49,31 @@ export const useTriggerOptimisticEffectFromSseUpdateEvents = () => { continue; } - upsertRecordsInStore({ partialRecords: [updatedRecord] }); + const unknownRecordInputFields = getUnknownOptimisticRecordInputFields({ + objectMetadataItem, + recordInput: updatedRecord, + }); + + const sanitizedUpdatedRecord = Object.fromEntries( + Object.entries(updatedRecord).filter( + ([recordKey]) => !unknownRecordInputFields.includes(recordKey), + ), + ) as Partial; + + upsertRecordsInStore({ + partialRecords: [sanitizedUpdatedRecord], + }); const computedOptimisticRecord = { ...computeOptimisticRecordFromInput({ cache: apolloCoreClient.cache, objectMetadataItem, objectMetadataItems, - recordInput: updatedRecord, + recordInput: sanitizedUpdatedRecord, objectPermissionsByObjectMetadataId, currentWorkspaceMember: null, }), - id: updatedRecord.id, + id: sanitizedUpdatedRecord.id, __typename: getObjectTypename(objectMetadataItem.nameSingular), };