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:
+20
-1
@@ -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');
|
||||
|
||||
+45
-34
@@ -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: () => {},
|
||||
|
||||
+21
-4
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user