Compare commits

...
Author SHA1 Message Date
sonarly-bot 504141e1b9 fix(front): handle missing morph target metadata gracefully
https://sonarly.com/issue/39017?type=bug

A front-end runtime error crashes `/objects/programs` when building GraphQL fields for a morph relation whose target object metadata is missing from the loaded metadata list.

Fix: I replaced the hard crash behavior for morph relation metadata drift with graceful degradation in the exact failing code path:

- In `generateDepthRecordGqlFieldsFromFields`, missing morph targets are now skipped instead of throwing.
- Existing valid morph targets still generate their GraphQL fields/IDs normally, so the page remains usable.
- When one or more morph targets are missing, the code reports a structured warning event (instead of an exception) through a dedicated helper.

I also added a targeted unit test that reproduces the mismatch (one present morph target + one missing target) and verifies:
1) no throw/crash behavior (function returns usable gql fields),
2) warning reporting is triggered with the expected payload.

Authored by Sonarly by autonomous analysis (run 44356).
2026-05-20 14:45:06 +00:00
4 changed files with 153 additions and 21 deletions
@@ -44,12 +44,14 @@ export const PromiseRejectionEffect = () => {
error?.networkError?.name === 'AbortError' ||
error?.name === 'AbortError';
if (!isAbortError) {
enqueueErrorSnackBar(
error instanceof Error ? { message: error.message } : {},
);
if (isAbortError) {
return;
}
enqueueErrorSnackBar(
error instanceof Error ? { message: error.message } : {},
);
try {
const { captureException } = await import('@sentry/react');
captureException(error, (scope) => {
@@ -0,0 +1,65 @@
import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields';
import { reportMissingMorphRelationTargetMetadata } from '@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
jest.mock(
'@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata',
() => ({
reportMissingMorphRelationTargetMetadata: jest.fn(),
}),
);
describe('generateDepthRecordGqlFieldsFromFields', () => {
it('should skip morph relations whose target object metadata is missing', () => {
const result = generateDepthRecordGqlFieldsFromFields({
objectMetadataItems: [
{
id: 'company-id',
fields: [],
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
nameSingular: 'company',
namePlural: 'companies',
},
],
fields: [
{
id: 'field-id',
name: 'company',
type: FieldMetadataType.MORPH_RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
relation: null,
morphRelations: [
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: 'company-id',
nameSingular: 'company',
namePlural: 'companies',
},
},
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
id: 'oem-plant-id',
nameSingular: 'oemPlant',
namePlural: 'oemPlants',
},
},
],
},
],
depth: 1,
});
expect(result).toEqual({
companyCompany: { id: true },
companyCompanyId: true,
});
expect(reportMissingMorphRelationTargetMetadata).toHaveBeenCalledWith({
fieldMetadataName: 'company',
missingMorphTargetNames: ['oemPlant'],
});
});
});
@@ -10,6 +10,7 @@ import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/
import { buildIdentifierGqlFields } from '@/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields';
import { generateActivityTargetGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields';
import { generateJunctionRelationGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields';
import { reportMissingMorphRelationTargetMetadata } from '@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import {
computeMorphRelationGqlFieldName,
@@ -127,7 +128,9 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
}
const morphGqlFields = fieldMetadata.morphRelations.map(
const missingMorphTargetNames: string[] = [];
const morphGqlFields = fieldMetadata.morphRelations.flatMap(
(morphRelation) => {
const morphTargetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
@@ -135,28 +138,39 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
if (!morphTargetObjectMetadataItem) {
throw new Error(
`Target object metadata item not found for ${fieldMetadata.name} (morph target ${morphRelation.targetObjectMetadata.nameSingular})`,
missingMorphTargetNames.push(
morphRelation.targetObjectMetadata.nameSingular,
);
return [];
}
return {
gqlField: computeMorphRelationGqlFieldName({
fieldName: fieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
relationIdentifierSubGqlFields: buildIdentifierGqlFields(
morphTargetObjectMetadataItem,
),
};
return [
{
gqlField: computeMorphRelationGqlFieldName({
fieldName: fieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
relationIdentifierSubGqlFields: buildIdentifierGqlFields(
morphTargetObjectMetadataItem,
),
},
];
},
);
if (missingMorphTargetNames.length > 0) {
reportMissingMorphRelationTargetMetadata({
fieldMetadataName: fieldMetadata.name,
missingMorphTargetNames,
});
}
return {
...recordGqlFields,
...morphGqlFields.reduce(
@@ -0,0 +1,51 @@
type ReportMissingMorphRelationTargetMetadataArgs = {
fieldMetadataName: string;
missingMorphTargetNames: string[];
};
const MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE =
'Missing morph target object metadata while generating record GraphQL fields';
export const reportMissingMorphRelationTargetMetadata = ({
fieldMetadataName,
missingMorphTargetNames,
}: ReportMissingMorphRelationTargetMetadataArgs) => {
const pathname =
typeof window === 'undefined' ? undefined : window.location.pathname;
const uniqueMissingMorphTargetNames = [...new Set(missingMorphTargetNames)];
// oxlint-disable-next-line no-console
console.warn(MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE, {
fieldMetadataName,
missingMorphTargetNames: uniqueMissingMorphTargetNames,
pathname,
});
import('@sentry/react')
.then(({ captureMessage, withScope }) => {
withScope((scope) => {
scope.setLevel('warning');
scope.setTag('error_type', 'morph_target_metadata_mismatch');
scope.setTag('field_name', fieldMetadataName);
scope.setExtra(
'missingMorphTargetNames',
uniqueMissingMorphTargetNames,
);
scope.setExtra('pathname', pathname);
scope.setFingerprint([
'morph_target_metadata_mismatch',
fieldMetadataName,
...[...uniqueMissingMorphTargetNames].sort(),
]);
captureMessage(MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE);
});
})
.catch((sentryError) => {
// oxlint-disable-next-line no-console
console.warn(
'Failed to capture morph target metadata mismatch warning with Sentry:',
sentryError,
);
});
};