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;
}