fix(front): skip unknown fields in SSE optimistic updates instead of dropping the event (#22474)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22474?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-03 09:31:42 +02:00
committed by GitHub
parent 429e8c4b84
commit d8ea406b80
5 changed files with 143 additions and 45 deletions
@@ -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([]);
});
});
@@ -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}`,
@@ -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<ObjectRecord>;
}): 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;
});
};
@@ -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) {
@@ -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,