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).
This commit is contained in:
sonarly-bot
2026-05-22 20:09:04 +00:00
parent eda41b4eba
commit bab771b718
4 changed files with 100 additions and 39 deletions
@@ -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');
@@ -32,6 +32,47 @@ type ComputeOptimisticCacheRecordInputArgs = {
GetRecordFromCacheArgs,
'cache' | 'objectMetadataItems' | 'objectPermissionsByObjectMetadataId'
>;
export const getUnknownOptimisticRecordInputFields = ({
objectMetadataItem,
recordInput,
}: Pick<ComputeOptimisticCacheRecordInputArgs, 'objectMetadataItem' | 'recordInput'>) => {
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}`,
@@ -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: () => {},
@@ -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<ObjectRecord>;
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),
};