fix: cannot create record from table view — empty morph to-many relation returns null (#21846)

## Problem

Creating a record from the table view (reproduced on **People**) crashes
the client even though the `createOne…` mutation succeeds server-side,
so the record never appears:

```
Cannot read properties of null (reading 'map')
  getRecordConnectionFromRecords → getRecordNodeFromRecord → optimistic cache effect → createOneRecord
```

## Root cause

An empty **morph** to-many relation comes back as `null`, while every
other to-many relation comes back as `{ edges: [] }`. The frontend then
runs `null.map` while building the optimistic cache node; the error
escapes the mutation `update`, the rollback evicts the record, and it
never lands in the table.

## Fix

**Server** — plain to-many relations are hydrated to `[]` and formatted
to `{ edges: [] }` by `ObjectRecordsToGraphqlConnectionHelper`; an empty
morph to-many was left undefined and the field was skipped (→ `null`).
Default an unset to-many value to `[]` so it goes through the **same
connection path as plain to-many relations**.

**Frontend** — defensive guard in `getRecordNodeFromRecord`: a to-many
relation whose value isn't an array is skipped instead of crashing,
mirroring the existing guard in `extractTargetRecordsFromRelation`.
Needed regardless, since cached data / SSE / older servers still send
`null`.

## Tests

- Unit: `getRecordNodeFromRecord` skips a null to-many (reproduces the
exact crash without the guard).
- Integration: an empty morph `ONE_TO_MANY` read returns `{ edges: []
}`, not null.
This commit is contained in:
Charles Bochet
2026-06-21 18:05:04 +02:00
committed by GitHub
parent a0689d1577
commit 334e962ab5
4 changed files with 229 additions and 1 deletions
@@ -3,6 +3,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
import { mockedPersonRecords } from '~/testing/mock-data/generated/data/people/mock-people-data';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
const peopleMock = [...mockedPersonRecords];
@@ -96,4 +97,55 @@ describe('getRecordNodeFromRecord', () => {
},
});
});
it('skips a to-many relation whose value is null instead of crashing', () => {
// Given
const objectMetadataItems: EnrichedObjectMetadataItem[] =
getTestEnrichedObjectMetadataItemsMock();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
if (!objectMetadataItem) {
throw new Error('Object metadata item not found');
}
const oneToManyRelationField = objectMetadataItem.fields.find(
(field) =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.ONE_TO_MANY,
);
if (!oneToManyRelationField) {
throw new Error('No to-many relation field found on person');
}
const record = {
...peopleMock[0],
[oneToManyRelationField.name]: null,
};
const recordGqlFields = {
name: true,
[oneToManyRelationField.name]: true,
};
// When / Then
expect(() =>
getRecordNodeFromRecord({
objectMetadataItems,
objectMetadataItem,
recordGqlFields,
record,
}),
).not.toThrow();
const result = getRecordNodeFromRecord({
objectMetadataItems,
objectMetadataItem,
recordGqlFields,
record,
});
expect(result).not.toHaveProperty(oneToManyRelationField.name);
});
});
@@ -67,6 +67,10 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.ONE_TO_MANY
) {
if (!Array.isArray(value)) {
return undefined;
}
const oneToManyObjectMetadataItem = objectMetadataItems.find(
(item) =>
item.namePlural ===
@@ -99,6 +103,10 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
field.type === FieldMetadataType.MORPH_RELATION &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
if (!Array.isArray(value)) {
return undefined;
}
if (field.morphRelations?.length === 0) {
return undefined;
}
@@ -17,6 +17,7 @@ import {
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util';
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -221,7 +222,13 @@ export class ObjectRecordsToGraphqlConnectionHelper {
objectRecord[fieldMetadataNameWithId];
}
const objectValue = objectRecord[fieldMetadata.name];
const isToManyRelation =
fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY;
const objectValue =
!isDefined(objectRecord[fieldMetadata.name]) && isToManyRelation
? []
: objectRecord[fieldMetadata.name];
if (!isDefined(objectValue)) {
continue;
@@ -0,0 +1,161 @@
import gql from 'graphql-tag';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { capitalize } from 'twenty-shared/utils';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
describe('empty morph to-many relation read', () => {
let opportunityObjectId = '';
let personObjectId = '';
let companyObjectId = '';
let morphFieldId = '';
beforeAll(async () => {
({
data: {
createOneObject: { id: opportunityObjectId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'oppForEmptyMorph',
namePlural: 'oppsForEmptyMorph',
labelSingular: 'Opp For Empty Morph',
labelPlural: 'Opps For Empty Morph',
icon: 'IconOpportunity',
},
}));
({
data: {
createOneObject: { id: personObjectId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'personForEmptyMorph',
namePlural: 'peopleForEmptyMorph',
labelSingular: 'Person For Empty Morph',
labelPlural: 'People For Empty Morph',
icon: 'IconUser',
},
}));
({
data: {
createOneObject: { id: companyObjectId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'companyForEmptyMorph',
namePlural: 'companiesForEmptyMorph',
labelSingular: 'Company For Empty Morph',
labelPlural: 'Companies For Empty Morph',
icon: 'IconBuildingSkyscraper',
},
}));
const createdField = await createMorphRelationBetweenObjects({
objectMetadataId: opportunityObjectId,
firstTargetObjectMetadataId: personObjectId,
secondTargetObjectMetadataId: companyObjectId,
type: FieldMetadataType.MORPH_RELATION,
relationType: RelationType.ONE_TO_MANY,
name: 'owner',
});
morphFieldId = createdField.id;
});
afterAll(async () => {
await deleteOneFieldMetadata({ input: { idToDelete: morphFieldId } }).catch(
() => {},
);
for (const idToDelete of [
opportunityObjectId,
personObjectId,
companyObjectId,
]) {
await updateOneObjectMetadata({
expectToFail: false,
input: { idToUpdate: idToDelete, updatePayload: { isActive: false } },
});
await deleteOneObjectMetadata({ input: { idToDelete } });
}
});
it('returns an empty connection (not null) for each morph to-many target', async () => {
await makeGraphqlAPIRequestWithApiKey(
createOneOperationFactory({
objectMetadataSingularName: 'oppForEmptyMorph',
gqlFields: 'id',
data: { name: 'No owners' },
}),
);
const { body: schemaBody } = await makeGraphqlAPIRequestWithApiKey({
query: gql`
query {
__type(name: "${capitalize('oppForEmptyMorph')}") {
fields {
name
type {
name
ofType {
name
}
}
}
}
}
`,
});
const morphConnectionFieldNames: string[] = schemaBody.data.__type.fields
.filter(
(field: {
name: string;
type: { name?: string; ofType?: { name?: string } };
}) => {
const typeName = field.type?.name ?? field.type?.ofType?.name;
return (
field.name.startsWith('owner') && typeName?.endsWith('Connection')
);
},
)
.map((field: { name: string }) => field.name);
expect(morphConnectionFieldNames.length).toBeGreaterThan(0);
const gqlFields = [
'id',
...morphConnectionFieldNames.map(
(fieldName) => `${fieldName} { edges { node { id } } }`,
),
].join('\n');
const { body } = await makeGraphqlAPIRequestWithApiKey(
findManyOperationFactory({
objectMetadataSingularName: 'oppForEmptyMorph',
objectMetadataPluralName: 'oppsForEmptyMorph',
gqlFields,
}),
);
expect(body.errors).toBeUndefined();
const node = body.data.oppsForEmptyMorph.edges[0].node;
for (const fieldName of morphConnectionFieldNames) {
expect(node[fieldName]).toEqual({ edges: [] });
}
});
});