From d8ea406b805afdf39404e7f361bff535bedb42a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 3 Jul 2026 09:31:42 +0200 Subject: [PATCH] fix(front): skip unknown fields in SSE optimistic updates instead of dropping the event (#22474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale When an SSE record-update event carries a field the tab's metadata cache doesn't know (someone added a custom field after this tab loaded), `computeOptimisticRecordFromInput` throws `Should never occur, encountered unknown fields …`. The catch in `useTriggerEventStreamCreation` swallows the throw, so the **entire event is discarded** — the tab silently stops reflecting that update. **Production evidence (Sentry):** the `Error while processing SSE message` family — ~860 events / ~480 users in the last 30 days, ongoing ([TWENTY-FRONT-7PD](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-7PD) et al.), with the sampled stack landing exactly on this throw. Related: [TWENTY-FRONT-633](https://twenty-v7.sentry.io/issues/TWENTY-FRONT-633) (903 users). ## Why this is the root cause, not a symptom patch The throw is an assertion that unknown fields "should never occur". That's the correct contract for the 4 local-mutation callers (`useUpdateOneRecord`, `useCreateOneRecord`, `useCreateManyRecords`, `useRunWorkflowVersion`) — there, an unknown field is a programming bug. But for the SSE caller the input comes from the **server**, which can legitimately be ahead of the tab's metadata. Schema convergence is the metadata-event pipeline's job (it flows over the same SSE channel); the record pipeline's job is to tolerate the window. So the fix moves the decision to the right caller instead of weakening the assertion for everyone: - `getUnknownRecordInputFields` — detection logic extracted, shared - mutation callers: still throw (behavior unchanged) - SSE update path: filters unknown fields and applies the rest of the event Dropping the *fields* loses nothing: the tab couldn't render them anyway without the metadata, and the metadata event that follows triggers the proper refresh. ## User impact ~480 users/month currently get silently stale tabs (list/kanban rows not reflecting teammates' updates) whenever any custom field is added while they have Twenty open. After this fix, updates keep flowing; only the not-yet-known field is skipped until metadata converges. ## Test plan - [x] Unit tests for `getUnknownRecordInputFields` (known fields, `__typename`, unknown fields, relation join columns) - [x] Existing `computeOptimisticRecordFromInput` tests cover the unchanged throw path - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ Review in cubic --- .../getUnknownRecordInputFields.test.ts | 52 ++++++++++++++++++ .../utils/computeOptimisticRecordFromInput.ts | 41 ++------------ .../utils/getUnknownRecordInputFields.ts | 53 +++++++++++++++++++ .../hooks/useTriggerEventStreamCreation.ts | 14 ++--- ...ggerOptimisticEffectFromSseUpdateEvents.ts | 28 +++++++++- 5 files changed, 143 insertions(+), 45 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/utils/__tests__/getUnknownRecordInputFields.test.ts create mode 100644 packages/twenty-front/src/modules/object-record/utils/getUnknownRecordInputFields.ts diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getUnknownRecordInputFields.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getUnknownRecordInputFields.test.ts new file mode 100644 index 0000000000..42b1bc823a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getUnknownRecordInputFields.test.ts @@ -0,0 +1,52 @@ +import { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +describe('getUnknownRecordInputFields', () => { + const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person'); + + it('should return an empty array when every field is known', () => { + const result = getUnknownRecordInputFields({ + objectMetadataItem: personObjectMetadataItem, + recordInput: { + city: 'Paris', + }, + }); + + expect(result).toEqual([]); + }); + + it('should ignore the __typename key', () => { + const result = getUnknownRecordInputFields({ + objectMetadataItem: personObjectMetadataItem, + recordInput: { + __typename: 'Person', + city: 'Paris', + }, + }); + + expect(result).toEqual([]); + }); + + it('should return fields absent from the object metadata', () => { + const result = getUnknownRecordInputFields({ + objectMetadataItem: personObjectMetadataItem, + recordInput: { + city: 'Paris', + someFieldCreatedInAnotherTab: 'value', + }, + }); + + expect(result).toEqual(['someFieldCreatedInAnotherTab']); + }); + + it('should accept relation join column names', () => { + const result = getUnknownRecordInputFields({ + objectMetadataItem: personObjectMetadataItem, + recordInput: { + companyId: '20202020-0713-40a5-8216-82802401d33e', + }, + }); + + expect(result).toEqual([]); + }); +}); 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 efe8f99789..9e311f4132 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeOptimisticRecordFromInput.ts @@ -2,13 +2,11 @@ import { isNull, isUndefined } from '@sniptt/guards'; import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; -import { getFieldMetadataFromGqlField } from '@/object-record/cache/utils/getFieldMetadataFromGqlField'; import { getMorphRelationFromFieldMetadataAndGqlField } from '@/object-record/cache/utils/getMorphRelationFromFieldMetadataAndGqlField'; import { getRecordFromCache, type GetRecordFromCacheArgs, } from '@/object-record/cache/utils/getRecordFromCache'; -import { GRAPHQL_TYPENAME_KEY } from '@/object-record/constants/GraphqlTypenameKey'; import { type FieldActorValue } from '@/object-record/record-field/ui/types/FieldMetadata'; import { isFieldActor } from '@/object-record/record-field/ui/types/guards/isFieldActor'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; @@ -17,6 +15,7 @@ import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFiel import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { buildOptimisticActorFieldValueFromCurrentWorkspaceMember } from '@/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember'; import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName'; +import { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields'; import { computeMorphRelationGqlFieldName, computeRelationGqlFieldJoinColumnName, @@ -40,41 +39,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 = getUnknownRecordInputFields({ + 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/object-record/utils/getUnknownRecordInputFields.ts b/packages/twenty-front/src/modules/object-record/utils/getUnknownRecordInputFields.ts new file mode 100644 index 0000000000..9f53a3d2f6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getUnknownRecordInputFields.ts @@ -0,0 +1,53 @@ +import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { getFieldMetadataFromGqlField } from '@/object-record/cache/utils/getFieldMetadataFromGqlField'; +import { GRAPHQL_TYPENAME_KEY } from '@/object-record/constants/GraphqlTypenameKey'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { + computeRelationGqlFieldJoinColumnName, + isDefined, +} from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const getUnknownRecordInputFields = ({ + objectMetadataItem, + recordInput, +}: { + objectMetadataItem: EnrichedObjectMetadataItem; + recordInput: Partial; +}): string[] => { + 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; + }); +}; 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 f3a324ccbf..d15e0a0d92 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 @@ -123,6 +123,8 @@ export const useTriggerEventStreamCreation = () => { (item) => item.objectRecordEvent, ); + dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents); + triggerOptimisticEffectFromSseEvents({ objectRecordEvents, }); @@ -130,8 +132,6 @@ export const useTriggerEventStreamCreation = () => { dispatchObjectRecordEventsFromSseToBrowserEvents( objectRecordEventsWithQueryIds, ); - - dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents); }, error: (error) => { captureException(error); @@ -184,6 +184,11 @@ export const useTriggerEventStreamCreation = () => { }, ); + const metadataEvents = + result?.data?.onEventSubscription?.metadataEvents ?? []; + + dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents); + triggerOptimisticEffectFromSseEvents({ objectRecordEvents, }); @@ -191,11 +196,6 @@ export const useTriggerEventStreamCreation = () => { dispatchObjectRecordEventsFromSseToBrowserEvents( objectRecordEventsWithQueryIds, ); - - const metadataEvents = - result?.data?.onEventSubscription?.metadataEvents ?? []; - - dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents); } } } catch (error) { 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 5b0408b845..6862a254c8 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 @@ -11,6 +11,8 @@ 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 { getUnknownRecordInputFields } from '@/object-record/utils/getUnknownRecordInputFields'; +import { captureMessage } from '@sentry/react'; import { useCallback } from 'react'; import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; import { @@ -39,12 +41,34 @@ export const useTriggerOptimisticEffectFromSseUpdateEvents = () => { }); for (const updateEvent of updateEvents) { - const updatedRecord = updateEvent.properties.after; + const recordFromEvent = updateEvent.properties.after; - if (!isDefined(updatedRecord)) { + if (!isDefined(recordFromEvent)) { continue; } + const unknownRecordInputFields = getUnknownRecordInputFields({ + objectMetadataItem, + recordInput: recordFromEvent, + }); + + if (unknownRecordInputFields.length > 0) { + captureMessage( + `SSE update event for ${objectMetadataItem.nameSingular} carried fields unknown to this tab's metadata: ${unknownRecordInputFields.join(', ')}`, + 'warning', + ); + } + + const updatedRecord = + unknownRecordInputFields.length > 0 + ? Object.fromEntries( + Object.entries(recordFromEvent).filter( + ([recordKey]) => + !unknownRecordInputFields.includes(recordKey), + ), + ) + : recordFromEvent; + const computedOptimisticRecord = { ...computeOptimisticRecordFromInput({ cache: apolloCoreClient.cache,