Compare commits

...
Author SHA1 Message Date
sonarly-bot 32f61d8ebc fix(front): guard deep equality in record store upsert
https://sonarly.com/issue/38125?type=bug

A frontend runtime exception crashes record synchronization on the Companies page when processing realtime record updates. The failure is in client-side deep comparison, not a server GraphQL failure.

Fix: I fixed the crash in the record-store upsert path by hardening the deep-equality comparison in:

- `packages/twenty-front/src/modules/object-record/record-store/hooks/useUpsertRecordsInStore.ts`

Change made:
- Wrapped the `isDeeplyEqual(filteredCurrentRecord, filteredPartialRecord)` call in a narrow `try/catch`.
- On comparator failure, fallback is to treat the record as changed and proceed with merge/update instead of throwing.
- This preserves synchronization behavior and prevents the SSE processing pipeline from crashing the Companies page when `deep-equal` throws (`iterator must be a function`).

I also added regression coverage in:

- `packages/twenty-front/src/modules/object-record/record-store/hooks/__tests__/useUpsertRecordsInStore.test.tsx`

New test:
- Mocks `isDeeplyEqual` to throw `TypeError`.
- Verifies upsert still updates the store (graceful fallback) instead of failing.

Authored by Sonarly by autonomous analysis (run 43478).
2026-05-17 11:37:36 +00:00
3 changed files with 118 additions and 9 deletions
@@ -2,16 +2,21 @@ import { act, renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import * as isDeeplyEqualModule from '~/utils/isDeeplyEqual';
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
describe('useUpsertRecordsInStore', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it('should insert a new recordStore when no current recordStore exists', () => {
const recordId = 'test-record-1';
@@ -112,6 +117,57 @@ describe('useUpsertRecordsInStore', () => {
});
});
it('should fallback to updating recordStore when deep compare throws', () => {
const recordId = 'test-record-3';
jest.spyOn(isDeeplyEqualModule, 'isDeeplyEqual').mockImplementation(() => {
throw new TypeError('iterator must be a function');
});
const { result } = renderHook(
() => {
const recordStore = useAtomFamilyStateValue(
recordStoreFamilyState,
recordId,
);
const { upsertRecordsInStore } = useUpsertRecordsInStore();
return { recordStore, upsertRecordsInStore };
},
{ wrapper: Wrapper },
);
act(() => {
result.current.upsertRecordsInStore({
partialRecords: [
{
id: recordId,
__typename: 'Person',
name: 'John Doe',
},
],
});
});
act(() => {
result.current.upsertRecordsInStore({
partialRecords: [
{
id: recordId,
__typename: 'Person',
name: 'Jane Doe',
},
],
});
});
expect(result.current.recordStore).toEqual({
id: recordId,
__typename: 'Person',
name: 'Jane Doe',
});
});
it('should not update when filtered values are deeply equal', () => {
const recordId = 'test-record-3';
@@ -49,7 +49,18 @@ export const useUpsertRecordsInStore = () => {
})
: currentRecord;
if (!isDeeplyEqual(filteredCurrentRecord, filteredPartialRecord)) {
let shouldUpdateRecordStore = true;
try {
shouldUpdateRecordStore = !isDeeplyEqual(
filteredCurrentRecord,
filteredPartialRecord,
);
} catch {
shouldUpdateRecordStore = true;
}
if (shouldUpdateRecordStore) {
const updatedRecord = {
...currentRecord,
...filteredPartialRecord,
@@ -191,12 +191,54 @@ export const useTriggerEventStreamCreation = () => {
}
}
} catch (error) {
const errorProcessingSSEMessage = new Error(
'Error while processing SSE message',
{ cause: error instanceof Error ? error : undefined },
);
const objectRecordEventsWithQueryIds =
result?.data?.onEventSubscription?.objectRecordEventsWithQueryIds ??
[];
captureException(errorProcessingSSEMessage);
const metadataEvents =
result?.data?.onEventSubscription?.metadataEvents ?? [];
const objectRecordEventActions = [
...new Set(
objectRecordEventsWithQueryIds.map(
({ objectRecordEvent }) => objectRecordEvent.action,
),
),
];
const objectRecordNames = [
...new Set(
objectRecordEventsWithQueryIds.map(
({ objectRecordEvent }) =>
objectRecordEvent.objectNameSingular,
),
),
];
const processingError =
error instanceof Error
? error
: new Error('Unknown error while processing SSE message');
captureException(processingError, (scope) => {
scope.setLevel('warning');
scope.setTag('eventType', event);
scope.setTag('eventStreamId', newSseEventStreamId);
scope.setExtras({
hasGraphqlErrors: isDefined(result?.errors),
objectRecordEventCount: objectRecordEventsWithQueryIds.length,
metadataEventCount: metadataEvents.length,
objectRecordEventActions,
objectRecordNames,
});
scope.setFingerprint([
'sse-message-processing-error',
event,
processingError.message,
]);
return scope;
});
}
},
},