fix(front): clean stale morph relations from metadata store on object deletion (#22681)

## Problem

After deleting a custom object (e.g. `meeting`), the app crashes with
"Sorry, something went wrong" on pages that load records referencing
that object through a morph relation. The console shows:

```
Target object metadata item not found for target (morph target meeting)
```

It reproduces on the machine that used the object before deletion but
not on a fresh machine, which points at a stale client metadata store
rather than a server issue.

## Root cause

Every field carries its own server-provided `morphRelations` array; a
morph relation field (note/task/timeline targets, etc.) lists every
object it can point to, including the deleted one. When an object is
deleted, `useDeleteOneObjectMetadataItem` and the SSE `delete` handler
only remove the deleted **object** and its own fields from the metadata
store. The sibling morph fields on other objects keep their now-dangling
`morphRelations` entry pointing at the deleted object.

Those stale entries were only meant to be cleaned up later by a
collection-hash-triggered `network-only` refetch. When that
reconciliation does not win, `generateDepthRecordGqlFieldsFromFields`
can't resolve the deleted morph target in `objectMetadataItems` and
throws, crashing the page.

## Fix

Clean `morphRelations` entries referencing the deleted object from the
field metadata store at deletion time, so the store stays
self-consistent immediately instead of relying on an async refetch.
Applied in both paths that handle object deletion:

- `useDeleteOneObjectMetadataItem` (the client performing the deletion)
- `MetadataStoreSSEEffect` delete handler (other tabs/clients receiving
the event)

The throw in `generateDepthRecordGqlFieldsFromFields` is intentionally
left in place so any genuine future metadata inconsistency still
surfaces rather than being silently swallowed.

## Test

Added a unit test for the cleaning util covering: morph relations
targeting the deleted object are removed, only changed fields are
returned, non-morph fields are untouched, and nothing is returned when
no relation targets the deleted object.
This commit is contained in:
Marie
2026-07-10 14:55:25 +02:00
committed by GitHub
parent 797acff5fa
commit f667ba500c
5 changed files with 148 additions and 0 deletions
@@ -1,4 +1,5 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useCleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState';
import { type MetadataEntityTypeMap } from '@/metadata-store/types/MetadataEntityTypeMap';
@@ -10,6 +11,8 @@ type AnyMetadataEntity = MetadataEntityTypeMap[MetadataEntityKey];
export const MetadataStoreSSEEffect = () => {
const { addToDraft, removeFromDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const { cleanMorphRelations } =
useCleanMorphRelationsTargetingObjectMetadataId();
useListenToMetadataOperationBrowserEvent({
onMetadataOperationBrowserEvent: (eventDetail) => {
@@ -50,6 +53,10 @@ export const MetadataStoreSSEEffect = () => {
itemIds: [eventDetail.operation.deletedRecordId],
collectionHash,
});
if (entityKey === 'objectMetadataItems') {
cleanMorphRelations(eventDetail.operation.deletedRecordId);
}
break;
}
}
@@ -0,0 +1,38 @@
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { cleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId';
import { useStore } from 'jotai';
import { useCallback } from 'react';
export const useCleanMorphRelationsTargetingObjectMetadataId = () => {
const store = useStore();
const { updateInDraft } = useUpdateMetadataStoreDraft();
const cleanMorphRelations = useCallback(
(deletedObjectMetadataId: string) => {
const entry = store.get(
metadataStoreState.atomFamily('fieldMetadataItems'),
);
const baseFieldMetadataItems = (
entry.status === 'draft-pending' ? entry.draft : entry.current
) as FlatFieldMetadataItem[];
const cleanedFieldMetadataItems =
cleanMorphRelationsTargetingObjectMetadataId(
baseFieldMetadataItems,
deletedObjectMetadataId,
);
if (cleanedFieldMetadataItems.length === 0) {
return;
}
updateInDraft('fieldMetadataItems', cleanedFieldMetadataItems);
},
[store, updateInDraft],
);
return { cleanMorphRelations };
};
@@ -0,0 +1,66 @@
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { cleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/utils/cleanMorphRelationsTargetingObjectMetadataId';
const buildMorphRelation = (targetObjectMetadataId: string) =>
({
targetObjectMetadata: {
id: targetObjectMetadataId,
nameSingular: targetObjectMetadataId,
namePlural: `${targetObjectMetadataId}s`,
},
}) as unknown as NonNullable<FlatFieldMetadataItem['morphRelations']>[number];
const buildField = (
id: string,
morphRelations: FlatFieldMetadataItem['morphRelations'],
): FlatFieldMetadataItem =>
({
id,
name: id,
objectMetadataId: 'source-object',
morphRelations,
}) as unknown as FlatFieldMetadataItem;
describe('cleanMorphRelationsTargetingObjectMetadataId', () => {
it('should remove morph relations targeting the deleted object and return only changed fields', () => {
const morphField = buildField('morph-field', [
buildMorphRelation('company-id'),
buildMorphRelation('meeting-id'),
]);
const untouchedField = buildField('other-morph-field', [
buildMorphRelation('company-id'),
]);
const nonMorphField = buildField('text-field', null);
const result = cleanMorphRelationsTargetingObjectMetadataId(
[morphField, untouchedField, nonMorphField],
'meeting-id',
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('morph-field');
expect(result[0].morphRelations).toEqual([
buildMorphRelation('company-id'),
]);
});
it('should keep a morph field with an empty morphRelations array when all its targets are deleted', () => {
const result = cleanMorphRelationsTargetingObjectMetadataId(
[buildField('exclusive-morph', [buildMorphRelation('meeting-id')])],
'meeting-id',
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('exclusive-morph');
expect(result[0].morphRelations).toEqual([]);
});
it('should return an empty array when no morph relation targets the deleted object', () => {
const result = cleanMorphRelationsTargetingObjectMetadataId(
[buildField('morph-field', [buildMorphRelation('company-id')])],
'meeting-id',
);
expect(result).toEqual([]);
});
});
@@ -0,0 +1,33 @@
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const cleanMorphRelationsTargetingObjectMetadataId = (
fieldMetadataItems: FlatFieldMetadataItem[],
deletedObjectMetadataId: string,
): FlatFieldMetadataItem[] => {
const cleanedFieldMetadataItems: FlatFieldMetadataItem[] = [];
for (const fieldMetadataItem of fieldMetadataItems) {
const morphRelations = fieldMetadataItem.morphRelations;
if (
!isDefined(morphRelations) ||
!morphRelations.some(
(morphRelation) =>
morphRelation.targetObjectMetadata.id === deletedObjectMetadataId,
)
) {
continue;
}
cleanedFieldMetadataItems.push({
...fieldMetadataItem,
morphRelations: morphRelations.filter(
(morphRelation) =>
morphRelation.targetObjectMetadata.id !== deletedObjectMetadataId,
),
});
}
return cleanedFieldMetadataItems;
};
@@ -2,6 +2,7 @@ import { useMutation } from '@apollo/client/react';
import { DeleteOneObjectMetadataItemDocument } from '~/generated-metadata/graphql';
import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidateMetadataStore';
import { useCleanMorphRelationsTargetingObjectMetadataId } from '@/metadata-store/hooks/useCleanMorphRelationsTargetingObjectMetadataId';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
@@ -19,6 +20,8 @@ export const useDeleteOneObjectMetadataItem = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft();
const { invalidateMetadataStore } = useInvalidateMetadataStore();
const { cleanMorphRelations } =
useCleanMorphRelationsTargetingObjectMetadataId();
const deleteOneObjectMetadataItem = async (
idToDelete: string,
@@ -35,6 +38,7 @@ export const useDeleteOneObjectMetadataItem = () => {
});
removeFromDraft({ key: 'objectMetadataItems', itemIds: [idToDelete] });
cleanMorphRelations(idToDelete);
applyChanges();
invalidateMetadataStore();